feat(turnstile): двухконтурный учет СКУД, реестры исключений с автокомплитом 1С и калибровка таймзон
This commit is contained in:
+120
-37
@@ -21,34 +21,50 @@ def has_yesterday_final_snapshot(date_str: str) -> bool:
|
|||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
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,)
|
(date_str,)
|
||||||
)
|
)
|
||||||
return cursor.fetchone() is not None
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
|
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()
|
Генерирует понятный и уникальный ID снапшота:
|
||||||
date_prefix = dt_snap.strftime("%Y%m%d")
|
- Дата префикса берется строго из даты самих логов (date_str).
|
||||||
except (ValueError, TypeError):
|
- Для итоговых срезов дня: YYYYYMMDD_FINAL (строго с буквой Y в начале).
|
||||||
dt_snap = datetime.now().date()
|
- Для дневных срезов на время: YYYYMMDD_HHMM.
|
||||||
date_prefix = dt_snap.strftime("%Y%m%d")
|
"""
|
||||||
|
|
||||||
if date_str:
|
if date_str:
|
||||||
try:
|
try:
|
||||||
dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
|
dt_log = datetime.strptime(date_str.replace('_', '.'), "%d.%m.%Y").date()
|
||||||
if dt_log < dt_snap:
|
date_prefix = dt_log.strftime("%Y%m%d")
|
||||||
is_yesterday = True
|
except Exception:
|
||||||
|
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")
|
||||||
|
|
||||||
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
prefix = "Y" if is_yesterday else ""
|
|
||||||
|
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# 1. Проверяем, существует ли уже срез с точно таким же временем и датой
|
# Если срез с точно таким же временем и датой уже существует — возвращаем его ID
|
||||||
if date_str:
|
if date_str:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
|
"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]:
|
if row and row[0]:
|
||||||
return 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("""
|
cursor.execute("""
|
||||||
SELECT DISTINCT snapshot_id
|
SELECT DISTINCT snapshot_id
|
||||||
FROM scud_logs
|
FROM scud_logs
|
||||||
WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
|
WHERE snapshot_id LIKE ?
|
||||||
""", (f"{date_prefix}-%", f"Y{date_prefix}-%"))
|
""", (f"{base_id}-%",))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
max_seq = 0
|
max_seq = 1
|
||||||
|
|
||||||
for (s_id,) in rows:
|
for (s_id,) in rows:
|
||||||
if not s_id:
|
if not s_id:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
# Извлекаем число после последнего дефиса
|
|
||||||
parts = str(s_id).split('-')
|
parts = str(s_id).split('-')
|
||||||
if len(parts) >= 2 and parts[-1].isdigit():
|
if len(parts) >= 2 and parts[-1].isdigit():
|
||||||
num = int(parts[-1])
|
num = int(parts[-1])
|
||||||
@@ -88,15 +115,16 @@ def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yeste
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
next_seq = max_seq + 1
|
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:
|
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:
|
if df_scud is None or df_scud.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
now_local_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
if not snapshot_time:
|
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)
|
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,
|
1 if r.get('Пришел', False) else 0,
|
||||||
r.get('anomaly_flag', 'NONE'),
|
r.get('anomaly_flag', 'NONE'),
|
||||||
snapshot_time,
|
snapshot_time,
|
||||||
snapshot_id
|
snapshot_id,
|
||||||
|
now_local_str # ⭐️ Передаем локальное время машины напрямую
|
||||||
)
|
)
|
||||||
for _, r in df_scud.iterrows()
|
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 (
|
INSERT INTO scud_logs (
|
||||||
log_date, fio, fio_clean, department, position,
|
log_date, fio, fio_clean, department, position,
|
||||||
time_in, first_activity, time_out, time_in_building,
|
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)
|
""", data_to_insert)
|
||||||
conn.commit()
|
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:
|
with get_connection() as conn:
|
||||||
df = pd.DataFrame()
|
df = pd.DataFrame()
|
||||||
|
|
||||||
# 1. Если передан конкретный ID снапшота (например 'Y20260820-004')
|
|
||||||
if snapshot_param:
|
if snapshot_param:
|
||||||
df = pd.read_sql_query(
|
df = pd.read_sql_query(
|
||||||
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
"SELECT * FROM scud_logs WHERE snapshot_id = ?",
|
||||||
conn, params=(str(snapshot_param),)
|
conn, params=(str(snapshot_param),)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот)
|
|
||||||
if df.empty and date_str:
|
if df.empty and date_str:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y'
|
|
||||||
cursor.execute(
|
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,)
|
(date_str,)
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
||||||
# Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени
|
|
||||||
if not row:
|
if not row:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
|
"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:
|
with get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
query = """
|
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
|
FROM scud_logs
|
||||||
WHERE snapshot_time IS NOT NULL
|
WHERE snapshot_time IS NOT NULL
|
||||||
"""
|
"""
|
||||||
@@ -239,11 +274,8 @@ def delete_snapshots_by_date(date_str: str) -> int:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return cnt
|
return cnt
|
||||||
|
|
||||||
|
|
||||||
def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
|
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:
|
if df_raw_events is None or df_raw_events.empty:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -257,6 +289,7 @@ def save_raw_events_to_db(df_raw_events: pd.DataFrame, date_str: str) -> int:
|
|||||||
str(r.get('Подразделение', '')),
|
str(r.get('Подразделение', '')),
|
||||||
int(r.get('Event', 0)),
|
int(r.get('Event', 0)),
|
||||||
int(r.get('Mode', 0)),
|
int(r.get('Mode', 0)),
|
||||||
|
int(r.get('DoorIndex')) if pd.notna(r.get('DoorIndex')) else None,
|
||||||
str(r.get('Direction', 'OTHER'))
|
str(r.get('Direction', 'OTHER'))
|
||||||
)
|
)
|
||||||
for _, r in df_raw_events.iterrows()
|
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("""
|
cursor.executemany("""
|
||||||
INSERT INTO scud_events_raw (
|
INSERT INTO scud_events_raw (
|
||||||
log_date, time_val, hoz_organ, fio, fio_clean,
|
log_date, time_val, hoz_organ, fio, fio_clean,
|
||||||
department, event_code, mode, direction
|
department, event_code, mode, door_index, direction
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", records)
|
""", records)
|
||||||
conn.commit()
|
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
|
||||||
@@ -50,6 +50,7 @@ def init_all_tables() -> None:
|
|||||||
department TEXT,
|
department TEXT,
|
||||||
event_code INTEGER NOT NULL,
|
event_code INTEGER NOT NULL,
|
||||||
mode INTEGER NOT NULL,
|
mode INTEGER NOT NULL,
|
||||||
|
door_index INTEGER DEFAULT 1,
|
||||||
direction TEXT NOT NULL, -- 'IN' или 'OUT'
|
direction TEXT NOT NULL, -- 'IN' или 'OUT'
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
reason
|
||||||
|
отгул
|
||||||
|
дежурство
|
||||||
|
обучение
|
||||||
|
экзамен в Ростехнадзоре
|
||||||
|
выходной день по ТД
|
||||||
|
@@ -2,7 +2,6 @@ fio,department,reason,date_from,date_to
|
|||||||
Королёва Наталья Александровна,Все,Удаленная работа,,
|
Королёва Наталья Александровна,Все,Удаленная работа,,
|
||||||
Николаева Ирина Леонидовна,Все,Удаленная работа,,
|
Николаева Ирина Леонидовна,Все,Удаленная работа,,
|
||||||
Познякова Татьяна Сергеевна,Все,Удаленная работа,,
|
Познякова Татьяна Сергеевна,Все,Удаленная работа,,
|
||||||
Софьин Никита Сергеевич,Все,Удаленная работа,,
|
|
||||||
Чуб Александр Васильевич,Все,Удаленная работа,,
|
Чуб Александр Васильевич,Все,Удаленная работа,,
|
||||||
Шуличенко Иван Иванович,Все,Удаленная работа,,
|
Шуличенко Иван Иванович,Все,Удаленная работа,,
|
||||||
Пухаренко Юрий Владимирович,Все,Удаленная работа,,
|
Пухаренко Юрий Владимирович,Все,Удаленная работа,,
|
||||||
|
|||||||
|
@@ -2,6 +2,27 @@
|
|||||||
|
|
||||||
Все важные изменения проекта документируются в этом файле.
|
Все важные изменения проекта документируются в этом файле.
|
||||||
|
|
||||||
|
## [2.5.8] - 2026-09-10
|
||||||
|
|
||||||
|
### Добавлено (Added)
|
||||||
|
- **Двухконтурная физическая модель СКУД (Правый PERCo):**
|
||||||
|
- Поддержка проходов через оба турникета (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
|
||||||
|
- Реестр доступа к правому турникету в `exceptions_registry` с категориями `turnstile_fio` и `turnstile_departments`.
|
||||||
|
- Корректная склейка ФИО (`Name + FirstName + MidName`) в динамическом SQL-фильтре MS SQL.
|
||||||
|
- **Интерактивное модальное окно исключений:**
|
||||||
|
- Замена браузерного `prompt()` на модальную форму в едином Tailwind-стиле с автоподбором сотрудников из 1С:ЗУП (`staff-autocomplete`).
|
||||||
|
- Поле примечания/комментария для фиксации оснований допуска.
|
||||||
|
- **Точное локальное время создания срезов:**
|
||||||
|
- Явная фиксация времени хоста при записи срезов в `scud_logs.created_at`, устраняющая 3-часовое смещение UTC внутри контейнеров.
|
||||||
|
- **Декларативное подтверждение удаления срезов:**
|
||||||
|
- Добавление параметра `confirmed: boolean` в схему `TOOLS_SCHEMA` инструмента `db_delete_snapshots`, исключающее зацикливание подтверждений в LLM.
|
||||||
|
|
||||||
|
### Изменено (Changed)
|
||||||
|
- **Упразднение искусственного автозакрытия смен:**
|
||||||
|
- Полное отключение механизма `calculate_autoclose_time` по согласованию с отделом кадров. При отсутствии физической отметки на турникете статус «Нет выхода» сохраняется без искажения аналитики.
|
||||||
|
- **Математический расчет временных границ срезов:**
|
||||||
|
- Перевод формирования `@EndDate` в `services/scud_export.py` на цепочку функций `DATEADD` от `@StartDate`, устраняющий зависимость от регионального формата даты (`DMY` vs `MDY`).
|
||||||
|
|
||||||
## [Unreleased] - 2026-09-07
|
## [Unreleased] - 2026-09-07
|
||||||
|
|
||||||
### Добавлено
|
### Добавлено
|
||||||
|
|||||||
+126
-106
@@ -1,176 +1,196 @@
|
|||||||
# План реализации (ROADMAP)
|
# План реализации (ROADMAP)
|
||||||
|
|
||||||
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
|
## 1. Очистка от регулярок и костылей (`tool_injector.py`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов.
|
- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 2].
|
||||||
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`).
|
- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (`<tool_call>`)[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
|
## 2. Настройка контекста и инструкций сессии (`agent.py`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`.
|
- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 2].
|
||||||
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
|
- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию[cite: 2]:
|
||||||
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты.
|
- **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 2].
|
||||||
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение.
|
- **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 2].
|
||||||
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью.
|
- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 2].
|
||||||
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`.
|
- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 2].
|
||||||
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов.
|
- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
|
## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**.
|
- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 2].
|
||||||
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения.
|
- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 2].
|
||||||
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`.
|
- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 2].
|
||||||
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста.
|
- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 2].
|
||||||
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории.
|
- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
|
## 4. Инлайн-редактор в окне диалога (`core.js`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**.
|
- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 2].
|
||||||
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением.
|
- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 2].
|
||||||
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`).
|
- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 2].
|
||||||
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика.
|
- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
|
## 5. Тестирование и валидация системного промпта `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`).
|
- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 2].
|
||||||
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail).
|
- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 2].
|
||||||
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`).
|
- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 2].
|
||||||
- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`.
|
- [x] **Строгий вызов Базы Знаний:** внедрено правило 2.9 в системный промпт для пресечения текстовой имитации вызова `db_get_rules`[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
|
## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Масштабирование UI задач:**
|
- [x] **Масштабирование UI задач:**
|
||||||
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`.
|
- Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 2].
|
||||||
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все».
|
- Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 2].
|
||||||
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих).
|
- Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 2].
|
||||||
- [x] **Инлайн-редактирование карточки задачи:**
|
- [x] **Инлайн-редактирование карточки задачи:**
|
||||||
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности).
|
- Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 2].
|
||||||
- Отображение даты создания задачи.
|
- Отображение даты создания задачи[cite: 2].
|
||||||
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`.
|
- Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 2].
|
||||||
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
|
- [x] **Детерминированный Fast-Path и двухфазное удаление:**
|
||||||
- Мгновенная смена статусов без задержек LLM.
|
- Мгновенная смена статусов без задержек LLM[cite: 2].
|
||||||
- Карточка подтверждения удаления с автоочисткой контекста.
|
- Карточка подтверждения удаления с автоочисткой контекста[cite: 2].
|
||||||
- [x] **Генерация отчетов задач в Markdown:**
|
- [x] **Генерация отчетов задач в Markdown:**
|
||||||
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`.
|
- Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 2].
|
||||||
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`).
|
- Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 2].
|
||||||
- [x] **Доменная консолидация задач:**
|
- [x] **Доменная консолидация задач:**
|
||||||
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`.
|
- Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
|
## 7. Распространение на модуль снапшотов (`snapshots`) `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00.
|
- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00 / 23:59:59[cite: 2].
|
||||||
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат.
|
- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 2].
|
||||||
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
|
- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
|
||||||
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке.
|
- Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 2].
|
||||||
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора).
|
- Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 2].
|
||||||
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки.
|
- Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 2].
|
||||||
- [x] **Детерминированный Fast-Path удаления срезов:**
|
- [x] **Декларативное управление удалением срезов:**
|
||||||
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов.
|
- Добавлен параметр `confirmed` в схему `TOOLS_SCHEMA` для `db_delete_snapshots`, исключающий зацикливание подтверждений в LLM.
|
||||||
- Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления.
|
- Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 2].
|
||||||
|
- Корректное отображение времени создания срезов в интерфейсе (устранение смещения UTC относительно локального времени сервера).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
|
## 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Выделение общего слоя ядра (`core/`):**
|
- [x] **Выделение общего слоя ядра (`core/`):**
|
||||||
- Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
|
- Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов[cite: 2].
|
||||||
- DDL-схемы таблиц и индексов (`core/schema.py`).
|
- DDL-схемы таблиц и индексов (`core/schema.py`)[cite: 2].
|
||||||
- Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
|
- Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`)[cite: 2].
|
||||||
- Фасад обратной совместимости (`core/database.py`).
|
- Фасад обратной совместимости (`core/database.py`)[cite: 2].
|
||||||
- [x] **Декомпозиция предметных доменов (`services/`):**
|
- [x] **Декомпозиция предметных доменов (`services/`):**
|
||||||
- Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`).
|
- Сервис задач (`services/tasks/`), системного промпта (`services/prompts/`), срезов СКУД (`services/snapshots/`), базы знаний (`services/knowledge/`)[cite: 2].
|
||||||
- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):**
|
- [x] **Рефакторинг ETL-конвейера (`services/scud_etl/`):**
|
||||||
- Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`.
|
- Оркестратор `pipeline.py`, детектор аномалий `anomaly_detector.py`, слияние `merger.py`[cite: 2].
|
||||||
- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):**
|
- [x] **Модульный ИИ-оркестратор (`modules/ai_engine/`):**
|
||||||
- Динамический строитель системного контекста (`context_builder.py`).
|
- Динамический строитель системного контекста (`context_builder.py`)[cite: 2].
|
||||||
- Изолированные обработчики инструментов в `handlers/`.
|
- Изолированные обработчики инструментов в `handlers/`[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и автозакрытие 8.5ч `[ЗАВЕРШЕНО]`
|
## 9. Реляционный реестр исключений, схлопывание мульти-пропусков и кадровый аудит `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
|
- [x] **Реестр исключений в SQLite (`exceptions_registry`):**
|
||||||
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД.
|
- Хранение категорий `departments`, `positions`, `fio`, `position_keywords`, `include_fio` в БД[cite: 2].
|
||||||
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`.
|
- Доменный сервис `exceptions_repo.py` и полная поддержка в CLI `scripts/db_cli.py exceptions`[cite: 2].
|
||||||
- [x] **Агрегация мульти-пропусков физлиц в СКУД:**
|
- [x] **Агрегация мульти-пропусков физлиц в СКУД:**
|
||||||
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие).
|
- Функция `aggregate_scud_by_person` в `merger.py`: объединение событий всех пропусков одного человека (ранний вход, поздний выход, присутствие)[cite: 2].
|
||||||
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`).
|
- Автоматическая фиксация аномалий дублирования пропусков (`DUPLICATE_SCUD_CARD`)[cite: 2].
|
||||||
- [x] **Умный выбор ставки совместителей 1С:ЗУП:**
|
- [x] **Умный выбор ставки совместителей 1С:ЗУП:**
|
||||||
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД.
|
- Функция `select_best_zup_position` в `merger.py`: привязка ставки 1С по подразделению физического нахождения в СКУД[cite: 2].
|
||||||
- [x] **Автозакрытие смен по Правилу 8.5ч:**
|
- [x] **Отказ от искусственного автозакрытия смен:**
|
||||||
- Для забывших отметиться на выходе офисных сотрудников: автоматический расчет `Вход + 8ч 30мин`, норма 8 часов и отклонение `0:00` в `excel_exporter.py`.
|
- Полное отключение механизма дорисовывания 8.5 часов (`calculate_autoclose_time`) по согласованию с отделом кадров.
|
||||||
|
- Сохранение честного статуса «Нет выхода» для прозрачности кадрового аудита и выявления нарушений.
|
||||||
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
|
- [x] **Кэш сопоставлений личностей (`person_identity_mapping`):**
|
||||||
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`.
|
- Таблица в SQLite и команды `db_cli.py mapping [list|add|del]`[cite: 2].
|
||||||
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`.
|
- Теневой режим ИИ-подсказок по нечетким ФИО в `text_reporter.py`[cite: 2].
|
||||||
- [x] **Каскадный fallback кадровых отсутствий:**
|
- [x] **Каскадный fallback кадровых отсутствий:**
|
||||||
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`.
|
- `MS SQL ЗУП` $\rightarrow$ резервный парсинг `Отсутствия_*.xlsx` $\rightarrow$ `static_reason_workers.csv`[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]`
|
## 10. Разделение генераторов и почасовые срезы `[ЗАВЕРШЕНО]`
|
||||||
- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`.
|
- [x] Разделение логики генерации на `svodka_generator.py` и `otchet_generator.py`[cite: 2].
|
||||||
- [x] Перевод времени суточного среза `Y` на `23:59:59`.
|
- [x] Перевод времени суточного среза `Y` на `23:59:59`[cite: 2].
|
||||||
- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин).
|
- [x] Интеллектуальный поиск срезов `services/snapshots/finder.py` (Snap-to-Grid ±20 мин)[cite: 2].
|
||||||
- [x] Флаг `--time` и интерактивный help в `main_etl.py`.
|
- [x] Флаг `--time` и интерактивный help в `main_etl.py`[cite: 2].
|
||||||
- [x] Флаг `--export-only` для почасового крона.
|
- [x] Флаг `--export-only` для почасового крона[cite: 2].
|
||||||
- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`.
|
- [x] Политика ночной ротации промежуточных срезов `services/snapshots/retention.py`[cite: 2].
|
||||||
- [x] Исправление целочисленного инкремента `snapshot_id`.
|
- [x] Исправление целочисленного инкремента `snapshot_id`[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
|
## 11. Стабилизация генератора отчетов и верстки `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии.
|
- [x] **Миграция на `XlsxWriter`:** полный перевод экспорта книг (`scud_export.py`, `excel_exporter.py`) на чистый генератор, устранивший ошибки OpenXML и окна восстановления при открытии[cite: 2].
|
||||||
- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`).
|
- [x] **Интерактивные группировки в Сводке:** восстановление сворачивания категорий по умолчанию (`level=1`, `collapsed=True`) и кнопок управления уровнями `[+]`/`[-]` (`outline_settings(symbols_below=False)`)[cite: 2].
|
||||||
- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки.
|
- [x] **Компактная экранная верстка Детального отчета:** центрирование столбца «Подразделение», расширение столбца «ФИО» (+10%, ширина 33), оптимизация ширины колонок «Первая активность» (11) и «Отклонение от нормы» (11) для отображения без горизонтальной прокрутки[cite: 2].
|
||||||
- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле.
|
- [x] **Защита от блокировок занятых файлов (Fallback Timestamp):** механизм перехвата `FileCreateError`/`OSError` в `safe_close_workbook` с сохранением копии при открытом в Excel файле[cite: 2].
|
||||||
- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах.
|
- [x] **Безопасная синхронизация 1С с сетевой шары:** переход на `shutil.copyfile` в `share_copier.py` для устранения сбоев прав доступа (`Operation not permitted`) на SMB/CIFS-ресурсах[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. UI/UX веб-интерфейса и скроллинг ленты сообщений `[ЗАВЕРШЕНО]`
|
## 12. UI/UX веб-интерфейса и управление реестрами `[ЗАВЕРШЕНО]`
|
||||||
- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области.
|
- [x] **Паттерн Gemini-скролла:** реализация функции `scrollToUserMessageTop` в `core.js`, позиционирующей свежий вопрос пользователя строго по верхней кромке видимой области[cite: 2].
|
||||||
- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана.
|
- [x] **Полное скрытие истории:** предыдущие объемные ответы модели гарантированно вытесняются за верхний край экрана[cite: 2].
|
||||||
- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста.
|
- [x] **Стабилизация панели ввода:** настройка `padding-bottom: 80vh` для `#chat-messages-container` в `index.html`, обеспечивающая необходимый запас высоты прокрутки без сдвига строки ввода текста[cite: 2].
|
||||||
|
- [x] **Модальные формы реестров с автокомплитом 1С:**
|
||||||
|
- Полный отказ от браузерного `prompt()` при работе со списками исключений.
|
||||||
|
- Единое Tailwind-окно для добавления исключений с живым поиском сотрудников по базе 1С:ЗУП (`staff-autocomplete`) и полем для комментария/основания.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
|
## 13. Архитектура обработки событий турникетов и двухконтурный учет `[ЗАВЕРШЕНО]`
|
||||||
- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`.
|
- [x] **Опора на физический факт прохода (Event 32):**
|
||||||
|
- Устранение потери событий из-за рассинхронизации меток Event 28 (разрешение) и Event 32 (проход).
|
||||||
|
- Переход на плоский SQL-запрос выборки первого входа (`Mode = 1`) и последнего выхода (`Mode = 2`).
|
||||||
|
- [x] **Двухконтурная модель турникетов (Левый/Правый PERCo):**
|
||||||
|
- Разрешение использования обоих турникетов (`DoorIndex IN (1, 2)`) для сотрудников дворовых служб (отделы `ЭТО`, `ЛЦ` и др.).
|
||||||
|
- Защита основного пула офисных сотрудников от транзитных отметок на правом турникете (`DoorIndex = 1`).
|
||||||
|
- Реестр «Пр. турникет» (`turnstile_fio`, `turnstile_departments`) в базе данных и веб-панели.
|
||||||
|
- Корректное сопоставление составных ФИО (`Name + FirstName + MidName`) в запросе к `pList`.
|
||||||
|
- [x] **Таблица сырых событий (`scud_events_raw`):**
|
||||||
|
- Создание DDL-схемы таблицы и логирование всех физических проходов дня параллельно со снапшотами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Внутридневной контроль и генерация отчетов из веб-интерфейса `[В РАБОТЕ]`
|
||||||
|
- [ ] **Моментальное создание срезов из UI:**
|
||||||
|
- [ ] Кнопка **«Создать моментальный срез»** в шапке хаба «Срезы».
|
||||||
|
- [ ] Вызов `scud_export.py` с фиксацией состояния на текущую минуту и добавлением среза в список без перезагрузки страницы.
|
||||||
|
- [ ] **Контекстные кнопки генерации в строках срезов:**
|
||||||
|
- [ ] Размещение кнопок быстрых действий напротив каждой карточки среза в сайдбаре:
|
||||||
|
- Для промежуточных срезов (`HH:00`): кнопка **«Сводка»** $\rightarrow$ запуск `svodka_generator.py` на момент среза.
|
||||||
|
- Для итоговых срезов (`_FINAL` / `Y`): кнопка **«Отчет»** $\rightarrow$ запуск `otchet_generator.py` со сверкой 1С:ЗУП за сутки.
|
||||||
|
- [ ] Фоновая сборка документа с отображением индикатора и автоматической выдачей ссылки на скачивание файла (`/api/v1/files/download/...`).
|
||||||
|
- [ ] **Выделенный экран оперативного мониторинга («Текущая сводка»):**
|
||||||
|
- [ ] Отдельная страница/вкладка оперативного контроля присутствия в реальном времени:
|
||||||
|
- Метрики: *Всего по штату*, *В здании прямо сейчас*, *На удаленке*, *В командировке*, *Отсутствуют без причины*.
|
||||||
|
- Быстрый поиск и фильтрация по подразделениям.
|
||||||
|
- Подсветка аномалий внутри дня (вход без выхода более 10 часов, активность без прохода турникета).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Интеллектуальный кадровый арбитраж ДО генерации отчетов `[В ПЛАНАХ]`
|
||||||
|
- [ ] **Двухконтурный вызов ИИ:** перенос арбитража не сопоставившихся персон на этап [2.5] ДО сохранения Excel-файлов `сводка.xlsx` и `отчет.xlsx`[cite: 2].
|
||||||
- [ ] **Якорный табельный номер (TabNo Matching):**
|
- [ ] **Якорный табельный номер (TabNo Matching):**
|
||||||
- [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП.
|
- [ ] Извлечение `TabNo` из MS SQL СКУД Орион и 1С:ЗУП[cite: 2].
|
||||||
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite.
|
- [ ] Добавление колонок `scud_tab_no` и `zup_tab_no` в SQLite[cite: 2].
|
||||||
- [ ] Защита от смены фамилий и опечаток через инвариант табельного номера.
|
- [ ] Защита от смены фамилий и опечаток через инвариант табельного номера[cite: 2].
|
||||||
- [ ] **4-уровневая система предохранителей (Guardrails):**
|
- [ ] **4-уровневая система предохранителей (Guardrails):**
|
||||||
- Уровень 1: Точный матч ФИО (100%).
|
- Уровень 1: Точный матч ФИО (100%)[cite: 2].
|
||||||
- Уровень 2: Матч по табельному номеру при расхождении ФИО.
|
- Уровень 2: Матч по табельному номеру при расхождении ФИО[cite: 2].
|
||||||
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С.
|
- Уровень 3: Валидация кэша `person_identity_mapping` с проверкой актуальности статуса в 1С[cite: 2].
|
||||||
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш.
|
- Уровень 4: ИИ-арбитраж с записью верифицированной связки в кэш[cite: 2].
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
|
## 16. Изолированная песочница кода (Code Execution Sandbox Engine) `[В ПЛАНАХ]`
|
||||||
- [ ] **Docker/gVisor контур:**
|
- [ ] **Docker/gVisor контур:**
|
||||||
- Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups).
|
- Создание изолированного контейнера без доступа к внешней сети (`network: none`) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 2].
|
||||||
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only.
|
- Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 2].
|
||||||
- [ ] **Динамические Python/Pandas вычисления:**
|
- [ ] **Динамические Python/Pandas вычисления:**
|
||||||
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету.
|
- Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 2].
|
||||||
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата.
|
- Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 2].
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Архитектура обработки событий турникетов и детализация проходов (Сырые логи СКУД) `[В РАБОТЕ]`
|
|
||||||
- [ ] **Итерация 1: Устранение фантомных выходов в боевом SQL (`scud_export.py`):**
|
|
||||||
- [ ] Перестроить блок `CASE WHEN` определения `Direction`: приоритет отдавать физическому направлению контроллера (`log.Mode = 1` -> `'IN'`, `log.Mode = 2` -> `'OUT'`).
|
|
||||||
- [ ] Исключить универсальные коды событий `28` (разблокировка створки) и `32` (проворот турникета) из жесткого перечня `'IN'`, так как они зеркальны для обоих направлений.
|
|
||||||
- [ ] Закрепить расчет первого входа `FirstIn` и последнего выхода `FilteredLastOut` (строго `OUT` позже `FirstIn`).
|
|
||||||
- [ ] **Итерация 2: DDL-схема и таблица сырых событий (`core/schema.py`):**
|
|
||||||
- [ ] Создать таблицу `scud_events_raw` (поля: `log_date`, `time_val`, `hoz_organ`, `fio_clean`, `event_code`, `mode`, `direction`, `created_at`).
|
|
||||||
- [ ] Настроить индексы `idx_raw_date_fio` и `idx_raw_mode` для быстрых выборок.
|
|
||||||
- [ ] **Итерация 3: Конвейер выгрузки сырых событий (`services/scud_export.py`):**
|
|
||||||
- [ ] Добавить сохранение всех фактов физических проходов (`Event = 32` / `Event = 28`) в `scud_events_raw` параллельно с формированием агрегированного снапшота в `scud_logs`.
|
|
||||||
- [ ] **Итерация 4: Модуль оперативного присутствия и внутридневного учета:**
|
|
||||||
- [ ] Реализовать инструмент «Кто прямо сейчас в здании» по последнему зафиксированному `Mode` сотрудника за текущие сутки.
|
|
||||||
- [ ] Подготовить расчет реального суммарного времени нахождения на рабочем месте с учетом перекуров, обедов и выходов за периметр.
|
|
||||||
+674
-180
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,14 @@ def process_chat_message(
|
|||||||
active_date = state_data.get("query_date", "выбранную дату")
|
active_date = state_data.get("query_date", "выбранную дату")
|
||||||
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
|
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
|
||||||
elif current_state_type == "SNAPSHOT_INSPECT":
|
elif current_state_type == "SNAPSHOT_INSPECT":
|
||||||
|
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
|
||||||
|
msg_l = user_message.lower().strip()
|
||||||
|
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
|
||||||
|
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
|
||||||
|
db_clear_session_state(session_id)
|
||||||
|
current_state_type = None
|
||||||
|
active_state_context = ""
|
||||||
|
else:
|
||||||
snap_id = state_data.get("snapshot_id", "")
|
snap_id = state_data.get("snapshot_id", "")
|
||||||
snap_date = state_data.get("log_date", "")
|
snap_date = state_data.get("log_date", "")
|
||||||
records = state_data.get("records", [])
|
records = state_data.get("records", [])
|
||||||
@@ -118,8 +126,10 @@ def process_chat_message(
|
|||||||
dump_str = "\n".join(lines)
|
dump_str = "\n".join(lines)
|
||||||
active_state_context = (
|
active_state_context = (
|
||||||
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
|
||||||
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, их времени входа/выхода, отделах или присутствии — "
|
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
|
||||||
f"ТЫ ОБЯЗАН брать данные исключительно из этого списка активного среза:\n{dump_str}\n"
|
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
|
||||||
|
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
|
||||||
|
f"Список сотрудников в активном срезе:\n{dump_str}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
|
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
|
||||||
|
|||||||
@@ -146,7 +146,11 @@ TOOLS_SCHEMA = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "db_get_snapshots",
|
"name": "db_get_snapshots",
|
||||||
"description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
|
"description": (
|
||||||
|
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
|
||||||
|
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
|
||||||
|
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
|
||||||
|
),
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -162,17 +166,21 @@ TOOLS_SCHEMA = [
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "db_delete_snapshots",
|
"name": "db_delete_snapshots",
|
||||||
"description": "Удаление снапшотов СКУД по идентификатору или дате.",
|
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"snapshot_id": {
|
"snapshot_id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Идентификатор конкретного снапшота"
|
"description": "Идентификатор или список идентификаторов через запятую"
|
||||||
},
|
},
|
||||||
"day_str": {
|
"day_str": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Дата всех снапшотов за день"
|
"description": "Дата всех снапшотов за день"
|
||||||
|
},
|
||||||
|
"confirmed": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Флаг окончательного подтверждения удаления пользователем"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from fastapi import FastAPI, HTTPException
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from routers.manual_absences import router as manual_absences_router
|
||||||
|
|
||||||
from routers.auth import router as auth_router
|
from routers.auth import router as auth_router
|
||||||
from routers.admin import router as admin_router
|
from routers.admin import router as admin_router
|
||||||
@@ -69,6 +70,7 @@ app.include_router(exceptions_router)
|
|||||||
app.include_router(snapshots_router)
|
app.include_router(snapshots_router)
|
||||||
app.include_router(remote_workers_router)
|
app.include_router(remote_workers_router)
|
||||||
app.include_router(context_router)
|
app.include_router(context_router)
|
||||||
|
app.include_router(manual_absences_router)
|
||||||
|
|
||||||
# ANCHOR[ROOT_STATIC_ROUTES]
|
# ANCHOR[ROOT_STATIC_ROUTES]
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
===============================================================================
|
||||||
|
FILE: modules/web_api/routers/manual_absences.py
|
||||||
|
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
|
||||||
|
===============================================================================
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from services.manual_absences_repo import (
|
||||||
|
search_staff_suggestions,
|
||||||
|
get_static_reasons,
|
||||||
|
add_manual_absence,
|
||||||
|
delete_manual_absence,
|
||||||
|
get_manual_absences_list
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
|
||||||
|
|
||||||
|
|
||||||
|
class AddAbsenceRequest(BaseModel):
|
||||||
|
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
|
||||||
|
fio: str
|
||||||
|
reason: Optional[str] = ""
|
||||||
|
department: Optional[str] = ""
|
||||||
|
position: Optional[str] = ""
|
||||||
|
date_start: Optional[str] = None
|
||||||
|
date_end: Optional[str] = None
|
||||||
|
comment: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/staff-autocomplete")
|
||||||
|
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
|
||||||
|
return search_staff_suggestions(q)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reasons")
|
||||||
|
def api_get_reasons():
|
||||||
|
return {"reasons": get_static_reasons()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def api_list_manual_absences(type: Optional[str] = None):
|
||||||
|
return {"items": get_manual_absences_list(type)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
def api_add_manual_absence(req: AddAbsenceRequest):
|
||||||
|
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
|
||||||
|
res_id = add_manual_absence(
|
||||||
|
absence_type=req.absence_type,
|
||||||
|
fio=req.fio,
|
||||||
|
reason=reason,
|
||||||
|
department=req.department,
|
||||||
|
position=req.position,
|
||||||
|
date_start=req.date_start,
|
||||||
|
date_end=req.date_end,
|
||||||
|
comment=req.comment
|
||||||
|
)
|
||||||
|
if not res_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
|
||||||
|
return {"status": "success", "id": res_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{item_id}")
|
||||||
|
def api_delete_manual_absence(item_id: int):
|
||||||
|
if not delete_manual_absence(item_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Запись не найдена")
|
||||||
|
return {"status": "success"}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
|
/* Запрещаем браузеру насильно удерживать скролл внизу при появлении ответа */
|
||||||
* {
|
* {
|
||||||
overflow-anchor: none !important;
|
overflow-anchor: none !important;
|
||||||
@@ -20,13 +19,8 @@
|
|||||||
|
|
||||||
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
|
/* ⭐️ Воздух снизу для возможности поднятия вопроса на самый верх */
|
||||||
#chat-messages-container {
|
#chat-messages-container {
|
||||||
/*
|
|
||||||
clamp(минимальный отступ, желаемый адаптивный, максимальный предел)
|
|
||||||
Это гарантирует, что на огромных экранах отступ не раздуется до бесконечности,
|
|
||||||
а на маленьких — не сожмет ленту в ноль.
|
|
||||||
*/
|
|
||||||
padding-bottom: clamp(400px, 85vh, 900px) !important;
|
padding-bottom: clamp(400px, 85vh, 900px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Принудительное увеличение шрифта сообщений чата */
|
/* Принудительное увеличение шрифта сообщений чата */
|
||||||
#chat-messages-container .message-content,
|
#chat-messages-container .message-content,
|
||||||
@@ -216,6 +210,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- МОДАЛЬНОЕ ОКНО: МЕСТНАЯ КОМАНДИРОВКА И ИНОЕ С АВТОКОМПЛИТОМ -->
|
||||||
|
<div id="manual-absence-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 id="manual-absence-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-location-dot text-indigo-600"></i>
|
||||||
|
<span>Добавление в реестр</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onclick="closeManualAbsenceModal()" class="text-slate-400 hover:text-slate-600">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Поле ФИО с автодополнением по 1С -->
|
||||||
|
<div class="relative">
|
||||||
|
<label class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника (автоподбор из 1С):</label>
|
||||||
|
<input type="text" id="manual-absence-fio-input" autocomplete="off" placeholder="Начните вводить фамилию..."
|
||||||
|
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50" />
|
||||||
|
<div id="manual-absence-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="manual-absence-dept" />
|
||||||
|
<input type="hidden" id="manual-absence-pos" />
|
||||||
|
|
||||||
|
<!-- Выпадающий список причин (только для "Иное") -->
|
||||||
|
<div id="manual-absence-reason-block" class="hidden">
|
||||||
|
<label class="block text-[11px] font-bold text-slate-600 mb-1">Причина отсутствия:</label>
|
||||||
|
<select id="manual-absence-reason-select" class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-600 mb-1">Начало:</label>
|
||||||
|
<input type="date" id="manual-absence-start-date"
|
||||||
|
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||||||
|
<span class="text-[10px] text-slate-400 mt-0.5 block">Пусто = сегодня</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-600 mb-1">Окончание:</label>
|
||||||
|
<input type="date" id="manual-absence-end-date"
|
||||||
|
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-700" />
|
||||||
|
<span class="text-[10px] text-slate-400 mt-0.5 block">По умолчанию: сегодня</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||||
|
<button type="button" onclick="closeManualAbsenceModal()"
|
||||||
|
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="submitManualAbsence()"
|
||||||
|
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- МОДАЛЬНОЕ ОКНО: ДОБАВЛЕНИЕ В РЕЕСТРЫ ИСКЛЮЧЕНИЙ И ТУРНИКЕТОВ -->
|
||||||
|
<div id="exception-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-md w-full p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 id="exception-modal-title" class="text-sm font-bold text-slate-800 flex items-center gap-2">
|
||||||
|
<i class="fa-solid fa-user-shield text-indigo-600"></i>
|
||||||
|
<span id="exception-modal-header-text">Добавление в реестр</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onclick="closeExceptionModal()" class="text-slate-400 hover:text-slate-600">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="exception-modal-form" onsubmit="submitExceptionModalForm(event)" class="flex flex-col gap-3">
|
||||||
|
<input type="hidden" id="exception-category-input" value="" />
|
||||||
|
|
||||||
|
<!-- Поле ввода значения с автокомплитом -->
|
||||||
|
<div class="relative">
|
||||||
|
<label id="exception-value-label" class="block text-[11px] font-bold text-slate-600 mb-1">ФИО сотрудника:</label>
|
||||||
|
<input type="text" id="exception-value-input" autocomplete="off" required
|
||||||
|
placeholder="Начните вводить фамилию..."
|
||||||
|
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||||||
|
<!-- Выпадающие подсказки из 1С -->
|
||||||
|
<div id="exception-suggestions" class="hidden absolute left-0 right-0 top-full mt-1 bg-white border border-slate-300 rounded-lg shadow-xl z-30 max-h-48 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Опциональный комментарий -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] font-bold text-slate-600 mb-1">Примечание / основание (опционально):</label>
|
||||||
|
<input type="text" id="exception-comment-input" placeholder="Например: служебная записка, водитель, лаборатория"
|
||||||
|
class="w-full text-xs px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:border-indigo-500 bg-slate-50 text-slate-800" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exception-error-msg" class="text-[11px] font-semibold text-rose-600 hidden"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 mt-2 pt-2 border-t border-slate-100">
|
||||||
|
<button type="button" onclick="closeExceptionModal()"
|
||||||
|
class="px-3.5 py-1.5 text-xs text-slate-600 rounded-lg hover:bg-slate-100 font-medium transition">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="exception-submit-btn"
|
||||||
|
class="px-4 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-xs font-bold shadow transition flex items-center gap-1.5">
|
||||||
|
<i class="fa-solid fa-check text-xs"></i>
|
||||||
|
<span>Добавить</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
|
<!-- МОДАЛЬНОЕ ОКНО АВТОРИЗАЦИИ -->
|
||||||
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
<div id="auth-modal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
|
||||||
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
<div class="bg-white rounded-2xl shadow-2xl border border-slate-200 max-w-sm w-full p-6 flex flex-col gap-4">
|
||||||
@@ -307,13 +411,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ (Версия v=2.5.6) -->
|
<!-- ПОДКЛЮЧЕНИЕ СКРИПТОВ -->
|
||||||
<script src="/static/js/auth.js?v=2.5.6"></script>
|
<script src="/static/js/auth.js?v=2.5.7"></script>
|
||||||
<script src="/static/js/tasks.js?v=2.5.6"></script>
|
<script src="/static/js/tasks.js?v=2.5.7"></script>
|
||||||
<script src="/static/js/sidebar.js?v=2.5.6"></script>
|
<script src="/static/js/manual_absences.js?v=2.5.7"></script>
|
||||||
<script src="/static/js/chat/task_widget.js?v=2.5.6"></script>
|
<script src="/static/js/sidebar.js?v=2.5.7"></script>
|
||||||
<script src="/static/js/chat/core.js?v=2.5.6"></script>
|
<script src="/static/js/chat/task_widget.js?v=2.5.7"></script>
|
||||||
<script src="/static/js/app.js?v=2.5.6"></script>
|
<script src="/static/js/chat/core.js?v=2.5.7"></script>
|
||||||
|
<script src="/static/js/app.js?v=2.5.7"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function showAuthModal() {
|
function showAuthModal() {
|
||||||
|
|||||||
@@ -25,27 +25,22 @@ function saveCommandToHistory(commandText) {
|
|||||||
chatHistoryIndex = -1;
|
chatHistoryIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: плавное выравнивание вопроса к верхней границе
|
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
|
||||||
function scrollToUserMessageTop() {
|
function scrollToUserMessageTop() {
|
||||||
const container = document.getElementById("chat-messages-container");
|
const container = document.getElementById("chat-messages-container");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const userBubbles = container.querySelectorAll(".user-chat-bubble");
|
const userBubbles = container.querySelectorAll(".user-chat-bubble");
|
||||||
const targetEl = userBubbles[userBubbles.length - 1] || container.lastElementChild;
|
const targetEl = userBubbles[userBubbles.length - 1];
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setTimeout(() => {
|
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
|
||||||
const containerTop = container.getBoundingClientRect().top;
|
|
||||||
const targetTop = targetEl.getBoundingClientRect().top;
|
|
||||||
|
|
||||||
const targetScroll = container.scrollTop + (targetTop - containerTop) - 16;
|
|
||||||
|
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: Math.max(0, targetScroll),
|
top: Math.max(0, targetScroll),
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
}, 50);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +94,7 @@ function appendUserMessage(text, filename = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const msgHtml = `
|
const msgHtml = `
|
||||||
<div id="${msgId}" class="user-chat-bubble flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
<div id="${msgId}" class="user-chat-bubble relative flex gap-3 max-w-4xl mx-auto w-full justify-end pt-3 scroll-mt-4">
|
||||||
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
|
<div class="flex-1 max-w-2xl bg-indigo-600 text-white rounded-2xl rounded-tr-none p-4 shadow-sm">
|
||||||
${fileBadge}
|
${fileBadge}
|
||||||
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
|
<div class="text-sm leading-relaxed whitespace-pre-wrap">${escapeHtml(text)}</div>
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* ===============================================================================
|
||||||
|
* FILE: modules/web_api/static/js/manual_absences.js
|
||||||
|
* ROLE: Модальные окна "Мест. командир.", "Иное", живой автокомплит ФИО из 1С:ЗУП
|
||||||
|
* и мгновенная синхронизация с боковой панелью SidebarManager.
|
||||||
|
* ===============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
|
||||||
|
let reasonsCache = [];
|
||||||
|
|
||||||
|
async function loadAbsenceReasons() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/manual-absences/reasons');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
reasonsCache = data.reasons || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ошибка загрузки причин:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManualAbsenceModal(type) {
|
||||||
|
activeAbsenceType = type;
|
||||||
|
const isTrip = type === 'LOCAL_TRIP';
|
||||||
|
const titleEl = document.getElementById('manual-absence-modal-title');
|
||||||
|
const reasonBlock = document.getElementById('manual-absence-reason-block');
|
||||||
|
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||||||
|
|
||||||
|
if (titleEl) {
|
||||||
|
titleEl.innerHTML = isTrip
|
||||||
|
? '<i class="fa-solid fa-location-dot text-indigo-600 mr-2"></i>Местная командировка'
|
||||||
|
: '<i class="fa-solid fa-clipboard-list text-purple-600 mr-2"></i>Иные причины отсутствия';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasonBlock && reasonSelect) {
|
||||||
|
if (isTrip) {
|
||||||
|
reasonBlock.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
reasonBlock.classList.remove('hidden');
|
||||||
|
reasonSelect.innerHTML = reasonsCache.map(r => `<option value="${r}">${r}</option>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сброс полей ввода
|
||||||
|
const fioInput = document.getElementById('manual-absence-fio-input');
|
||||||
|
const deptInput = document.getElementById('manual-absence-dept');
|
||||||
|
const posInput = document.getElementById('manual-absence-pos');
|
||||||
|
const startDateInput = document.getElementById('manual-absence-start-date');
|
||||||
|
const endDateInput = document.getElementById('manual-absence-end-date');
|
||||||
|
const suggestionsBox = document.getElementById('manual-absence-suggestions');
|
||||||
|
|
||||||
|
if (fioInput) fioInput.value = '';
|
||||||
|
if (deptInput) deptInput.value = '';
|
||||||
|
if (posInput) posInput.value = '';
|
||||||
|
if (startDateInput) startDateInput.value = '';
|
||||||
|
if (suggestionsBox) {
|
||||||
|
suggestionsBox.classList.add('hidden');
|
||||||
|
suggestionsBox.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Окончание по умолчанию — сегодняшний день
|
||||||
|
if (endDateInput) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
endDateInput.value = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadManualAbsencesTable();
|
||||||
|
const modal = document.getElementById('manual-absence-modal');
|
||||||
|
if (modal) modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeManualAbsenceModal() {
|
||||||
|
const modal = document.getElementById('manual-absence-modal');
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Живой автокомплит ФИО из базы zup_staff
|
||||||
|
let searchTimeout = null;
|
||||||
|
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
|
||||||
|
const box = document.getElementById(suggestionsBoxId);
|
||||||
|
if (!inputEl || !box) return;
|
||||||
|
|
||||||
|
inputEl.addEventListener('input', function() {
|
||||||
|
const val = this.value.trim();
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
if (val.length < 2) {
|
||||||
|
box.classList.add('hidden');
|
||||||
|
box.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const items = await res.json();
|
||||||
|
if (items.length === 0) {
|
||||||
|
box.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
box.innerHTML = items.map(it => `
|
||||||
|
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
|
||||||
|
onclick="selectStaffSuggestion('${escapeHtml(it.fio)}', '${escapeHtml(it.department)}', '${escapeHtml(it.position)}')">
|
||||||
|
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
|
||||||
|
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStaffSuggestion(fio, dept, pos) {
|
||||||
|
const fioInput = document.getElementById('manual-absence-fio-input');
|
||||||
|
const deptInput = document.getElementById('manual-absence-dept');
|
||||||
|
const posInput = document.getElementById('manual-absence-pos');
|
||||||
|
const box = document.getElementById('manual-absence-suggestions');
|
||||||
|
|
||||||
|
if (fioInput) fioInput.value = fio;
|
||||||
|
if (deptInput) deptInput.value = dept;
|
||||||
|
if (posInput) posInput.value = pos;
|
||||||
|
if (box) box.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitManualAbsence() {
|
||||||
|
const fioInput = document.getElementById('manual-absence-fio-input');
|
||||||
|
const fio = fioInput ? fioInput.value.trim() : '';
|
||||||
|
if (!fio) {
|
||||||
|
alert('Укажите ФИО сотрудника');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
|
||||||
|
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
|
||||||
|
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
|
||||||
|
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
|
||||||
|
const reasonSelect = document.getElementById('manual-absence-reason-select');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
absence_type: activeAbsenceType,
|
||||||
|
fio: fio,
|
||||||
|
department: deptVal,
|
||||||
|
position: posVal,
|
||||||
|
date_start: startDateVal,
|
||||||
|
date_end: endDateVal,
|
||||||
|
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/manual-absences/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
if (fioInput) fioInput.value = '';
|
||||||
|
loadManualAbsencesTable();
|
||||||
|
// Обновляем список карточек в боковой панели и закрываем окно
|
||||||
|
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
|
||||||
|
SidebarManager.renderContent();
|
||||||
|
}
|
||||||
|
closeManualAbsenceModal();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка добавления записи');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Сетевая ошибка при добавлении');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManualAbsencesTable() {
|
||||||
|
const tableContainer = document.getElementById('manual-absences-table-body');
|
||||||
|
if (!tableContainer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/?type=${activeAbsenceType}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
tableContainer.innerHTML = '<tr><td colspan="5" class="text-center p-4 text-xs text-slate-400">Нет активных записей</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableContainer.innerHTML = items.map(it => `
|
||||||
|
<tr class="border-b border-slate-100 text-xs hover:bg-slate-50">
|
||||||
|
<td class="p-2 font-bold text-slate-800">${escapeHtml(it.fio)}</td>
|
||||||
|
<td class="p-2 text-slate-500">${escapeHtml(it.department || '—')}</td>
|
||||||
|
<td class="p-2 text-slate-600">${escapeHtml(it.reason)}</td>
|
||||||
|
<td class="p-2 text-center text-slate-500 font-mono text-[11px]">${it.date_start || '—'} / ${it.date_end || '—'}</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
<button onclick="deleteManualAbsenceRecord(${it.id})" class="text-slate-400 hover:text-rose-600 transition p-1" title="Удалить">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteManualAbsenceRecord(id) {
|
||||||
|
if (!confirm('Удалить эту запись?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
loadManualAbsencesTable();
|
||||||
|
if (window.SidebarManager && typeof SidebarManager.renderContent === 'function') {
|
||||||
|
SidebarManager.renderContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка при удалении');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadAbsenceReasons();
|
||||||
|
setupStaffAutocomplete(
|
||||||
|
document.getElementById('manual-absence-fio-input'),
|
||||||
|
'manual-absence-suggestions'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* ===============================================================================
|
* ===============================================================================
|
||||||
* FILE: modules/web_api/static/js/sidebar.js
|
* FILE: modules/web_api/static/js/sidebar.js
|
||||||
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами,
|
* ROLE: Контроллер левого сайдбара с 5-хабовой навигацией, реестрами (2x2),
|
||||||
* подробным описанием управления контекстом и интеграцией чата.
|
* модальным окном добавления исключений с автокомплитом из 1С:ЗУП.
|
||||||
* ===============================================================================
|
* ===============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Глобальная функция безопасного экранирования HTML
|
|
||||||
window.escapeHtml = function(str) {
|
window.escapeHtml = function(str) {
|
||||||
if (str === null || str === undefined) return '';
|
if (str === null || str === undefined) return '';
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -31,10 +30,13 @@ window.SidebarManager = {
|
|||||||
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
|
{ id: 'CONTEXT', label: 'Контекст', icon: 'fa-comments' }
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Сетка реестров 2x2
|
||||||
subTabs: {
|
subTabs: {
|
||||||
'REGISTRIES': [
|
'REGISTRIES': [
|
||||||
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
|
{ id: 'REMOTE', label: 'Удаленщики', icon: 'fa-house-laptop' },
|
||||||
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' }
|
{ id: 'EXCEPTIONS', label: 'Исключения', icon: 'fa-user-shield' },
|
||||||
|
{ id: 'LOCAL_TRIP', label: 'Мест. командир.', icon: 'fa-location-dot' },
|
||||||
|
{ id: 'OTHER', label: 'Иное', icon: 'fa-clipboard-list' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -79,17 +81,17 @@ window.SidebarManager = {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 2. Подвкладки (только для хабов, где они требуются, например Реестры)
|
// 2. Подвкладки реестров (Сетка 2x2)
|
||||||
let subTabsHtml = '';
|
let subTabsHtml = '';
|
||||||
if (this.subTabs[this.currentHub]) {
|
if (this.subTabs[this.currentHub]) {
|
||||||
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
|
const currentActiveSub = this.currentSubTab[this.currentHub] || this.subTabs[this.currentHub][0].id;
|
||||||
subTabsHtml = `
|
subTabsHtml = `
|
||||||
<div class="flex items-center gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
|
<div class="grid grid-cols-2 gap-1.5 p-1.5 bg-slate-100/90 border-b border-slate-200">
|
||||||
${this.subTabs[this.currentHub].map(st => {
|
${this.subTabs[this.currentHub].map(st => {
|
||||||
const isSubActive = currentActiveSub === st.id;
|
const isSubActive = currentActiveSub === st.id;
|
||||||
return `
|
return `
|
||||||
<button onclick="SidebarManager.setSubTab('${st.id}')"
|
<button onclick="SidebarManager.setSubTab('${st.id}')"
|
||||||
class="flex-1 py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
|
class="py-1 px-2 rounded-md text-[11px] font-semibold flex items-center justify-center gap-1.5 transition ${
|
||||||
isSubActive
|
isSubActive
|
||||||
? 'bg-white text-indigo-700 shadow-sm'
|
? 'bg-white text-indigo-700 shadow-sm'
|
||||||
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
|
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'
|
||||||
@@ -196,14 +198,16 @@ window.SidebarManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ)
|
// ХАБ 3: РЕЕСТРЫ (УДАЛЕНЩИКИ + ИСКЛЮЧЕНИЯ + МЕСТ. КОМАНДИР. + ИНОЕ)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
renderRegistriesView(container) {
|
renderRegistriesView(container) {
|
||||||
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
|
const subTab = this.currentSubTab['REGISTRIES'] || 'REMOTE';
|
||||||
if (subTab === 'REMOTE') {
|
if (subTab === 'REMOTE') {
|
||||||
this.renderRemoteWorkersView(container);
|
this.renderRemoteWorkersView(container);
|
||||||
} else {
|
} else if (subTab === 'EXCEPTIONS') {
|
||||||
this.renderExceptionsView(container);
|
this.renderExceptionsView(container);
|
||||||
|
} else {
|
||||||
|
this.renderManualAbsencesView(container, subTab);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -289,22 +293,32 @@ window.SidebarManager = {
|
|||||||
{ key: 'include_fio', title: 'Белый список (ФИО)' },
|
{ key: 'include_fio', title: 'Белый список (ФИО)' },
|
||||||
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
|
{ key: 'fio', title: 'Исключенные сотрудники (ФИО)' },
|
||||||
{ key: 'departments', title: 'Исключенные отделы' },
|
{ key: 'departments', title: 'Исключенные отделы' },
|
||||||
{ key: 'positions', title: 'Исключенные должности' }
|
{ key: 'positions', title: 'Исключенные должности' },
|
||||||
|
{ key: 'turnstile_fio', title: 'Пр. турникет (ФИО)', badge: 'Оба турникета' },
|
||||||
|
{ key: 'turnstile_departments', title: 'Пр. турникет (Отделы)', badge: 'Оба турникета' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const html = categories.map(cat => {
|
const html = categories.map(cat => {
|
||||||
const items = data[cat.key] || [];
|
const items = data[cat.key] || [];
|
||||||
|
const isTurnstile = cat.key.startsWith('turnstile_');
|
||||||
|
const badgeHtml = cat.badge
|
||||||
|
? `<span class="px-1.5 py-0.2 rounded text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">${cat.badge}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="bg-white border border-slate-200 rounded-xl p-3 flex flex-col gap-2 shadow-sm">
|
<div class="bg-white border ${isTurnstile ? 'border-emerald-200/80 bg-emerald-50/10' : 'border-slate-200'} rounded-xl p-3 flex flex-col gap-2 shadow-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
|
<span class="font-bold text-xs text-slate-700">${cat.title} (${items.length})</span>
|
||||||
<button onclick="SidebarManager.addExceptionPrompt('${cat.key}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
|
${badgeHtml}
|
||||||
|
</div>
|
||||||
|
<button onclick="openExceptionModal('${cat.key}', '${cat.title}')" class="text-indigo-600 hover:text-indigo-800 text-xs font-bold">
|
||||||
+ Добавить
|
+ Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
${items.map(it => `
|
${items.map(it => `
|
||||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-700 border border-slate-200">
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] ${isTurnstile ? 'bg-emerald-50 text-emerald-800 border border-emerald-200' : 'bg-slate-100 text-slate-700 border border-slate-200'}">
|
||||||
${escapeHtml(it)}
|
${escapeHtml(it)}
|
||||||
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
|
<button onclick="SidebarManager.deleteExceptionItem('${cat.key}', '${escapeHtml(it)}')" class="hover:text-rose-600 ml-0.5">×</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -320,24 +334,8 @@ window.SidebarManager = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async addExceptionPrompt(category) {
|
|
||||||
const val = prompt(`Введите значение для категории [${category}]:`);
|
|
||||||
if (!val || !val.trim()) return;
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/v1/exceptions/", {
|
|
||||||
method: "POST",
|
|
||||||
headers: AuthManager.getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ category: category, value: val.trim() })
|
|
||||||
});
|
|
||||||
if (res.ok) this.renderContent();
|
|
||||||
else alert("Ошибка добавления");
|
|
||||||
} catch (e) {
|
|
||||||
alert("Ошибка сети");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteExceptionItem(category, value) {
|
async deleteExceptionItem(category, value) {
|
||||||
if (!confirm(`Удалить "${value}" из ${category}?`)) return;
|
if (!confirm(`Удалить "${value}" из реестра?`)) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
|
const res = await fetch(`/api/v1/exceptions/?category=${encodeURIComponent(category)}&value=${encodeURIComponent(value)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -350,6 +348,73 @@ window.SidebarManager = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async renderManualAbsencesView(container, absenceType) {
|
||||||
|
const typeLabel = absenceType === 'LOCAL_TRIP' ? 'местных командировок' : 'иных отсутствий';
|
||||||
|
container.innerHTML = `<div class="text-center py-8 text-xs text-slate-400"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Загрузка ${typeLabel}...</div>`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/?type=${absenceType}`);
|
||||||
|
const data = res.ok ? await res.json() : { items: [] };
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
const listHtml = items.map(it => {
|
||||||
|
const dFrom = it.date_start ? it.date_start : 'сегодня';
|
||||||
|
const dTo = it.date_end ? it.date_end : 'сегодня';
|
||||||
|
const periodLabel = (dFrom === dTo) ? `на ${dFrom}` : `${dFrom} — ${dTo}`;
|
||||||
|
const badgeText = absenceType === 'LOCAL_TRIP' ? 'Местная командировка' : escapeHtml(it.reason);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-white border border-slate-200 rounded-xl text-xs gap-2 shadow-sm hover:border-indigo-300 transition">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-bold text-slate-800 truncate">${escapeHtml(it.fio)}</div>
|
||||||
|
<div class="text-[10px] text-slate-400 truncate">${escapeHtml(it.department || 'Все')} · ${badgeText}</div>
|
||||||
|
<div class="mt-0.5 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||||
|
<i class="fa-regular fa-calendar-days text-[8px]"></i>
|
||||||
|
<span>${escapeHtml(periodLabel)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-0.5 shrink-0">
|
||||||
|
<button onclick="SidebarManager.deleteManualAbsenceRecord(${it.id})"
|
||||||
|
class="text-slate-400 hover:text-rose-600 p-1.5 rounded-lg hover:bg-rose-50 transition"
|
||||||
|
title="Удалить">
|
||||||
|
<i class="fa-solid fa-trash-can text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-2 flex flex-col gap-2.5">
|
||||||
|
<div class="flex items-center justify-between px-1">
|
||||||
|
<span class="text-xs font-bold text-slate-700">В реестре: ${items.length} чел.</span>
|
||||||
|
<button onclick="openManualAbsenceModal('${absenceType}')" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-[11px] font-bold shadow-sm flex items-center gap-1 transition">
|
||||||
|
<i class="fa-solid fa-plus text-[10px]"></i> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5 max-h-[70vh] overflow-y-auto">
|
||||||
|
${items.length > 0 ? listHtml : '<div class="text-center py-8 text-xs text-slate-400">Список пуст</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="p-4 text-xs text-rose-500 text-center">Ошибка загрузки реестра</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteManualAbsenceRecord(id) {
|
||||||
|
if (!confirm("Удалить эту запись из реестра?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
this.renderContent();
|
||||||
|
} else {
|
||||||
|
alert("Ошибка удаления");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ошибка сети");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
|
// ХАБ 4: СИСТЕМНЫЙ ПРОМПТ И БАЗА ЗНАНИЙ
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -381,7 +446,7 @@ window.SidebarManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ (С ПОДРОБНЫМ ОПИСАНИЕМ)
|
// ХАБ 5: УПРАВЛЕНИЕ КОНТЕКСТОМ СЕССИИ
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
renderContextView(container) {
|
renderContextView(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
@@ -432,7 +497,6 @@ window.SidebarManager = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очищаем локальное окно чата до стартового приветствия
|
|
||||||
const chatContainer = document.getElementById("chat-messages-container");
|
const chatContainer = document.getElementById("chat-messages-container");
|
||||||
if (chatContainer) {
|
if (chatContainer) {
|
||||||
chatContainer.innerHTML = `
|
chatContainer.innerHTML = `
|
||||||
@@ -465,8 +529,145 @@ window.SidebarManager = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// МОДАЛЬНОЕ ОКНО ИСКЛЮЧЕНИЙ И АВТОКОМПЛИТ 1С
|
||||||
|
// ============================================================================
|
||||||
|
window.openExceptionModal = function(category, title = "") {
|
||||||
|
const modal = document.getElementById("exception-modal");
|
||||||
|
const headerText = document.getElementById("exception-modal-header-text");
|
||||||
|
const catInput = document.getElementById("exception-category-input");
|
||||||
|
const valInput = document.getElementById("exception-value-input");
|
||||||
|
const labelEl = document.getElementById("exception-value-label");
|
||||||
|
const commentInput = document.getElementById("exception-comment-input");
|
||||||
|
const errEl = document.getElementById("exception-error-msg");
|
||||||
|
const suggestionsBox = document.getElementById("exception-suggestions");
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
if (errEl) errEl.classList.add("hidden");
|
||||||
|
if (suggestionsBox) {
|
||||||
|
suggestionsBox.classList.add("hidden");
|
||||||
|
suggestionsBox.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catInput) catInput.value = category;
|
||||||
|
if (commentInput) commentInput.value = "";
|
||||||
|
if (valInput) valInput.value = "";
|
||||||
|
|
||||||
|
if (headerText) headerText.innerText = title || "Добавление в реестр";
|
||||||
|
|
||||||
|
if (labelEl && valInput) {
|
||||||
|
if (category.includes("fio")) {
|
||||||
|
labelEl.innerText = "ФИО сотрудника (автоподбор из 1С):";
|
||||||
|
valInput.placeholder = "Начните вводить фамилию...";
|
||||||
|
} else if (category.includes("department")) {
|
||||||
|
labelEl.innerText = "Подразделение:";
|
||||||
|
valInput.placeholder = "Например: ЭТО, ЛЦ, ОВК";
|
||||||
|
} else {
|
||||||
|
labelEl.innerText = "Должность:";
|
||||||
|
valInput.placeholder = "Например: Уборщик, Слесарь";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
if (valInput) valInput.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeExceptionModal = function() {
|
||||||
|
const modal = document.getElementById("exception-modal");
|
||||||
|
if (modal) modal.classList.add("hidden");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.submitExceptionModalForm = async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const category = document.getElementById("exception-category-input").value;
|
||||||
|
const value = document.getElementById("exception-value-input").value.trim();
|
||||||
|
const comment = document.getElementById("exception-comment-input")?.value.trim() || "";
|
||||||
|
const errEl = document.getElementById("exception-error-msg");
|
||||||
|
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/exceptions/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: AuthManager.getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ category: category, value: value, comment: comment })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeExceptionModal();
|
||||||
|
if (window.SidebarManager) SidebarManager.renderContent();
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
if (errEl) {
|
||||||
|
errEl.innerText = err.detail || "Ошибка сохранения";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (errEl) {
|
||||||
|
errEl.innerText = "Ошибка соединения с сервером";
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let excSearchTimeout = null;
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
if (window.AuthManager && AuthManager.isAuthenticated()) {
|
if (window.AuthManager && AuthManager.isAuthenticated()) {
|
||||||
SidebarManager.init();
|
SidebarManager.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputEl = document.getElementById("exception-value-input");
|
||||||
|
const box = document.getElementById("exception-suggestions");
|
||||||
|
|
||||||
|
if (inputEl && box) {
|
||||||
|
inputEl.addEventListener("input", function() {
|
||||||
|
const category = document.getElementById("exception-category-input")?.value || "";
|
||||||
|
if (!category.includes("fio")) {
|
||||||
|
box.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = this.value.trim();
|
||||||
|
clearTimeout(excSearchTimeout);
|
||||||
|
if (val.length < 2) {
|
||||||
|
box.classList.add("hidden");
|
||||||
|
box.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
excSearchTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const items = await res.json();
|
||||||
|
if (items.length === 0) {
|
||||||
|
box.classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
box.innerHTML = items.map(it => `
|
||||||
|
<div class="p-2 hover:bg-indigo-50 cursor-pointer border-b border-slate-100 flex flex-col text-xs"
|
||||||
|
onclick="selectExceptionStaff('${escapeHtml(it.fio)}')">
|
||||||
|
<span class="font-bold text-slate-800">${escapeHtml(it.fio)}</span>
|
||||||
|
<span class="text-[10px] text-slate-500">${escapeHtml(it.department)} · ${escapeHtml(it.position)}</span>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
box.classList.remove("hidden");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.selectExceptionStaff = function(fio) {
|
||||||
|
const inputEl = document.getElementById("exception-value-input");
|
||||||
|
const box = document.getElementById("exception-suggestions");
|
||||||
|
if (inputEl) inputEl.value = fio;
|
||||||
|
if (box) {
|
||||||
|
box.classList.add("hidden");
|
||||||
|
box.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
Executable
+12
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /home/puh/projects/scud_ai
|
||||||
|
mkdir -p /home/puh/projects/scud_ai/logs
|
||||||
|
|
||||||
|
echo "[CRON HOURLY SNAPSHOT START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
|
||||||
|
|
||||||
|
# Запуск ТОЛЬКО экспорта среза СКУД без тяжелых отчетов:
|
||||||
|
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
|
||||||
|
|
||||||
|
echo "[CRON HOURLY SNAPSHOT FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Переход в папку проекта
|
|
||||||
cd /home/puh/projects/scud_ai
|
cd /home/puh/projects/scud_ai
|
||||||
|
|
||||||
# Создание папки для логов
|
|
||||||
mkdir -p /home/puh/projects/scud_ai/logs
|
mkdir -p /home/puh/projects/scud_ai/logs
|
||||||
|
|
||||||
# Фиксация старта
|
|
||||||
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
||||||
echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
||||||
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
||||||
|
|
||||||
# Запуск main_etl.py через интерпретатор окружения
|
|
||||||
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1
|
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1
|
||||||
|
|
||||||
# Фиксация окончания
|
|
||||||
echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
||||||
echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /home/puh/projects/scud_ai
|
||||||
|
mkdir -p /home/puh/projects/scud_ai/logs
|
||||||
|
|
||||||
|
echo "[CRON HOURLY START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
|
||||||
|
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/services/scud_export.py >> /home/puh/projects/scud_ai/logs/cron_hourly.log 2>&1
|
||||||
|
echo "[CRON HOURLY FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_hourly.log
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd /home/puh/projects/scud_ai
|
||||||
|
mkdir -p /home/puh/projects/scud_ai/logs
|
||||||
|
|
||||||
|
echo "[CRON REPORTS START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
|
||||||
|
/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py --use-existing-snapshot >> /home/puh/projects/scud_ai/logs/cron_reports.log 2>&1
|
||||||
|
echo "[CRON REPORTS FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_reports.log
|
||||||
+30
-2
@@ -7,6 +7,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from core.repositories.scud_repo import get_building_presence
|
||||||
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
|
from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
|
||||||
from core.database import (
|
from core.database import (
|
||||||
get_connection,
|
get_connection,
|
||||||
@@ -450,6 +451,26 @@ def print_exceptions():
|
|||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
print("=" * 80 + "\n")
|
print("=" * 80 + "\n")
|
||||||
|
|
||||||
|
def print_building_presence(date_str: str, all_statuses: bool = False):
|
||||||
|
"""Выводит оперативный список сотрудников, находящихся в здании."""
|
||||||
|
records = get_building_presence(date_str, only_inside=not all_statuses)
|
||||||
|
|
||||||
|
title = f"КТО СЕЙЧАС В ЗДАНИИ [{date_str}]" if not all_statuses else f"ОПЕРАТИВНЫЙ СТАТУС СОТРУДНИКОВ [{date_str}]"
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print(f"🏢 {title} (Всего: {len(records)})")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
print(" Нет данных о проходах за указанную дату.")
|
||||||
|
else:
|
||||||
|
print(f"{'ФИО':<35} | {'Подразделение':<15} | {'Время':<10} | {'Статус':<8}")
|
||||||
|
print("-" * 80)
|
||||||
|
for r in records:
|
||||||
|
time_short = r['last_event_time'].split()[-1][:8] if ' ' in r['last_event_time'] else r['last_event_time'][:8]
|
||||||
|
print(f"{r['fio']:<35} | {r['department'][:15]:<15} | {time_short:<10} | {r['status']:<8}")
|
||||||
|
|
||||||
|
print("=" * 80 + "\n")
|
||||||
|
|
||||||
|
|
||||||
HELP_TEXT = """
|
HELP_TEXT = """
|
||||||
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
|
CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
|
||||||
@@ -458,6 +479,7 @@ CLI-утилита инспекции и управления SQLite базой
|
|||||||
stats -- Общая статистика строк по всем таблицам БД
|
stats -- Общая статистика строк по всем таблицам БД
|
||||||
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
|
snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
|
||||||
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
|
scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
|
||||||
|
in_building [ДД.ММ.ГГГГ] [--all] -- Оперативный статус: кто сейчас в здании (или все статусы с флагом --all)
|
||||||
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
|
absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
|
||||||
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
|
anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
|
||||||
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
|
rules -- Посмотреть правила Базы Знаний ИИ из SQLite
|
||||||
@@ -478,6 +500,8 @@ CLI-утилита инспекции и управления SQLite базой
|
|||||||
python scripts/db_cli.py stats
|
python scripts/db_cli.py stats
|
||||||
python scripts/db_cli.py snapshots 06.08.2026
|
python scripts/db_cli.py snapshots 06.08.2026
|
||||||
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
|
python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
|
||||||
|
python scripts/db_cli.py in_building 07.09.2026
|
||||||
|
python scripts/db_cli.py in_building 07.09.2026 --all
|
||||||
python scripts/db_cli.py absences 07.08.2026
|
python scripts/db_cli.py absences 07.08.2026
|
||||||
python scripts/db_cli.py prompts
|
python scripts/db_cli.py prompts
|
||||||
python scripts/db_cli.py tools
|
python scripts/db_cli.py tools
|
||||||
@@ -509,11 +533,11 @@ def main():
|
|||||||
parser.add_argument('command', nargs='?', default=None, choices=[
|
parser.add_argument('command', nargs='?', default=None, choices=[
|
||||||
'stats', 'snapshots', 'scud', 'absences', 'anomalies',
|
'stats', 'snapshots', 'scud', 'absences', 'anomalies',
|
||||||
'rules', 'prompts', 'sessions', 'dump', 'snapshot',
|
'rules', 'prompts', 'sessions', 'dump', 'snapshot',
|
||||||
'tools', 'context', 'exceptions'
|
'tools', 'context', 'in_building', 'exceptions'
|
||||||
], help="Основная команда")
|
], help="Основная команда")
|
||||||
parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
|
parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
|
||||||
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
|
parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
|
||||||
parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio'], help="Категория исключения")
|
parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio', 'turnstile_fio', 'turnstile_departments'], help="Категория исключения")
|
||||||
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
|
parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
|
||||||
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
|
parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
|
||||||
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
|
parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
|
||||||
@@ -549,6 +573,10 @@ def main():
|
|||||||
print_session_states()
|
print_session_states()
|
||||||
elif args.command == 'tools':
|
elif args.command == 'tools':
|
||||||
print_tool_actions()
|
print_tool_actions()
|
||||||
|
elif args.command in ('in_building', 'presence'):
|
||||||
|
# Принимаем дату из позиционного параметра action (или param), либо берем текущую
|
||||||
|
target_date = args.action if args.action else datetime.now().strftime("%d.%m.%Y")
|
||||||
|
print_building_presence(target_date, all_statuses=args.all)
|
||||||
elif args.command == 'context':
|
elif args.command == 'context':
|
||||||
if args.action in ['purge', 'clear']:
|
if args.action in ['purge', 'clear']:
|
||||||
is_all = args.all or (args.param == '--all')
|
is_all = args.all or (args.param == '--all')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: scripts/diagnostics/make_etl_snapshot.py
|
FILE: scripts/diagnostics/make_etl_snapshot.py
|
||||||
ROLE: Генерация компактного слепка ETL-конвейера, БД и сервисов СКУД.
|
ROLE: Генерация компактного слепка ETL-конвейера, генераторов отчетов и БД.
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -11,30 +11,42 @@ import os
|
|||||||
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
OUTPUT_FILE = os.path.join(ROOT_DIR, "etl_code_snapshot.md")
|
OUTPUT_FILE = os.path.join(ROOT_DIR, "etl_code_snapshot.md")
|
||||||
|
|
||||||
# Строгий целевой список файлов для ETL-слепка
|
|
||||||
TARGET_FILES = [
|
TARGET_FILES = [
|
||||||
|
# Конфигурация и точка входа
|
||||||
"config.py",
|
"config.py",
|
||||||
"exceptions.json",
|
"exceptions.json",
|
||||||
"main_etl.py",
|
"main_etl.py",
|
||||||
"run_cron_etl.sh",
|
"scripts/db_cli.py",
|
||||||
"scripts/db_cli.py", # ⭐️ Гарантированно включен
|
|
||||||
|
# Крон скрипты
|
||||||
|
"scripts/cron/run_hourly_snapshot.sh",
|
||||||
|
"scripts/cron/run_reports_only.sh",
|
||||||
|
"scripts/cron/run_cron_etl.sh",
|
||||||
|
|
||||||
|
# Ядро БД
|
||||||
"core/connection.py",
|
"core/connection.py",
|
||||||
"core/database.py",
|
"core/database.py",
|
||||||
"core/schema.py",
|
"core/schema.py",
|
||||||
"core/repositories/scud_repo.py",
|
"core/repositories/scud_repo.py",
|
||||||
"core/repositories/zup_repo.py",
|
"core/repositories/zup_repo.py",
|
||||||
|
|
||||||
|
# Сервисный слой загрузки и реестров
|
||||||
"services/data_loader.py",
|
"services/data_loader.py",
|
||||||
"services/scud_export.py",
|
"services/scud_export.py",
|
||||||
"services/share_copier.py",
|
"services/share_copier.py",
|
||||||
"services/excel_exporter.py",
|
"services/excel_exporter.py",
|
||||||
"services/text_reporter.py",
|
|
||||||
"services/exceptions_repo.py",
|
"services/exceptions_repo.py",
|
||||||
|
"services/manual_absences_repo.py",
|
||||||
"services/zup_extractor.py",
|
"services/zup_extractor.py",
|
||||||
"services/ai_verifier.py",
|
"services/ai_verifier.py",
|
||||||
"services/knowledge_base.py",
|
"services/knowledge_base.py",
|
||||||
"services/knowledge/service.py",
|
"services/knowledge/service.py",
|
||||||
|
|
||||||
|
# Модули сборки Сводки и Отчета
|
||||||
"services/scud_etl/pipeline.py",
|
"services/scud_etl/pipeline.py",
|
||||||
"services/scud_etl/merger.py",
|
"services/scud_etl/merger.py",
|
||||||
|
"services/scud_etl/svodka_generator.py",
|
||||||
|
"services/scud_etl/otchet_generator.py",
|
||||||
"services/scud_etl/anomaly_detector.py",
|
"services/scud_etl/anomaly_detector.py",
|
||||||
"services/snapshots/service.py",
|
"services/snapshots/service.py",
|
||||||
"services/tasks/repository.py",
|
"services/tasks/repository.py",
|
||||||
@@ -50,7 +62,12 @@ def create_etl_snapshot():
|
|||||||
full_path = os.path.join(ROOT_DIR, rel_path)
|
full_path = os.path.join(ROOT_DIR, rel_path)
|
||||||
if os.path.exists(full_path):
|
if os.path.exists(full_path):
|
||||||
ext = os.path.splitext(rel_path)[1].replace(".", "")
|
ext = os.path.splitext(rel_path)[1].replace(".", "")
|
||||||
lang = "py" if ext == "py" else ("json" if ext == "json" else "bash")
|
lang_map = {
|
||||||
|
"py": "py",
|
||||||
|
"json": "json",
|
||||||
|
"sh": "bash"
|
||||||
|
}
|
||||||
|
lang = lang_map.get(ext, "text")
|
||||||
try:
|
try:
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
file_text = f.read()
|
file_text = f.read()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
===============================================================================
|
===============================================================================
|
||||||
FILE: scripts/diagnostics/make_web_snapshot.py
|
FILE: scripts/diagnostics/make_web_snapshot.py
|
||||||
ROLE: Генерация слепка Web API, LLM-движка и клиентских скриптов.
|
ROLE: Генерация актуального слепка Web API, фронтенда (HTML/JS) и LLM-движка.
|
||||||
===============================================================================
|
===============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,37 +12,56 @@ ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|||||||
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
|
OUTPUT_FILE = os.path.join(ROOT_DIR, "web_api_code_snapshot.md")
|
||||||
|
|
||||||
WEB_TARGET_FILES = [
|
WEB_TARGET_FILES = [
|
||||||
|
# Главная точка входа и роутеры
|
||||||
"modules/web_api/main.py",
|
"modules/web_api/main.py",
|
||||||
"modules/web_api/routers/chat.py",
|
"modules/web_api/routers/chat.py",
|
||||||
"modules/web_api/routers/auth.py",
|
"modules/web_api/routers/remote_workers.py",
|
||||||
"modules/web_api/routers/tasks.py",
|
"modules/web_api/routers/manual_absences.py",
|
||||||
"modules/web_api/routers/exceptions.py",
|
"modules/web_api/routers/exceptions.py",
|
||||||
|
"modules/web_api/routers/snapshots.py",
|
||||||
|
"modules/web_api/routers/tasks.py",
|
||||||
|
"modules/web_api/routers/auth.py",
|
||||||
"modules/web_api/routers/admin.py",
|
"modules/web_api/routers/admin.py",
|
||||||
"modules/web_api/routers/files.py",
|
"modules/web_api/routers/files.py",
|
||||||
|
"modules/web_api/routers/context.py",
|
||||||
|
|
||||||
|
# LLM ядро
|
||||||
"modules/web_api/llm/agent.py",
|
"modules/web_api/llm/agent.py",
|
||||||
"modules/web_api/llm/db_tools.py",
|
|
||||||
"modules/web_api/llm/schemas.py",
|
"modules/web_api/llm/schemas.py",
|
||||||
"modules/web_api/llm/core/context_manager.py",
|
"modules/web_api/llm/db_tools.py",
|
||||||
"modules/web_api/llm/core/tool_injector.py",
|
|
||||||
"modules/web_api/llm/core/ollama_client.py",
|
|
||||||
"modules/web_api/llm/core/fast_path.py",
|
"modules/web_api/llm/core/fast_path.py",
|
||||||
"modules/web_api/static/js/app.js",
|
"modules/web_api/llm/core/context_manager.py",
|
||||||
"modules/web_api/static/js/auth.js",
|
"modules/web_api/llm/core/ollama_client.py",
|
||||||
|
"modules/web_api/llm/core/tool_injector.py",
|
||||||
|
|
||||||
|
# Фронтенд (Разметка и клиентские скрипты)
|
||||||
|
"modules/web_api/static/index.html",
|
||||||
|
"modules/web_api/static/js/sidebar.js",
|
||||||
"modules/web_api/static/js/tasks.js",
|
"modules/web_api/static/js/tasks.js",
|
||||||
|
"modules/web_api/static/js/manual_absences.js",
|
||||||
"modules/web_api/static/js/chat/core.js",
|
"modules/web_api/static/js/chat/core.js",
|
||||||
"modules/web_api/static/js/chat/task_widget.js"
|
"modules/web_api/static/js/chat/task_widget.js",
|
||||||
|
"modules/web_api/static/js/auth.js",
|
||||||
|
"modules/web_api/static/js/app.js"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def create_web_snapshot():
|
def create_web_snapshot():
|
||||||
content = ["# 🌐 WEB API & LLM AGENT CODE SNAPSHOT\n"]
|
content = ["# 🌐 WEB API & FRONTEND CODE SNAPSHOT\n"]
|
||||||
included_count = 0
|
included_count = 0
|
||||||
|
|
||||||
for rel_path in WEB_TARGET_FILES:
|
for rel_path in WEB_TARGET_FILES:
|
||||||
full_path = os.path.join(ROOT_DIR, rel_path)
|
full_path = os.path.join(ROOT_DIR, rel_path)
|
||||||
if os.path.exists(full_path):
|
if os.path.exists(full_path):
|
||||||
ext = os.path.splitext(rel_path)[1].replace(".", "")
|
ext = os.path.splitext(rel_path)[1].replace(".", "")
|
||||||
lang = "js" if ext == "js" else ("py" if ext == "py" else "text")
|
lang_map = {
|
||||||
|
"js": "js",
|
||||||
|
"py": "py",
|
||||||
|
"html": "html",
|
||||||
|
"css": "css",
|
||||||
|
"json": "json"
|
||||||
|
}
|
||||||
|
lang = lang_map.get(ext, "text")
|
||||||
try:
|
try:
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
file_text = f.read()
|
file_text = f.read()
|
||||||
@@ -50,12 +69,14 @@ def create_web_snapshot():
|
|||||||
included_count += 1
|
included_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
|
print(f"[⚠️] Ошибка чтения {rel_path}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[ℹ️] Пропущен отсутствующий файл: {rel_path}")
|
||||||
|
|
||||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(content))
|
f.write("\n".join(content))
|
||||||
|
|
||||||
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
|
size_kb = os.path.getsize(OUTPUT_FILE) / 1024
|
||||||
print(f"\n[✓] Web API слепок создан: {OUTPUT_FILE}")
|
print(f"\n[✓] Web API + Фронтенд слепок создан: {OUTPUT_FILE}")
|
||||||
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
|
print(f" Включено файлов: {included_count} | Размер: {size_kb:.1f} KB\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -181,9 +181,33 @@ def load_absent_data(date_str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" [⚠️] Ошибка обработки static_reason_workers.csv: {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
|
return df_absent if not df_absent.empty else None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def load_1c_data_smart(date_str, use_db=False):
|
def load_1c_data_smart(date_str, use_db=False):
|
||||||
df_staff = None
|
df_staff = None
|
||||||
df_absent = None
|
df_absent = None
|
||||||
|
|||||||
+16
-21
@@ -71,20 +71,6 @@ def safe_close_workbook(wb, output_path, target_dir, filename):
|
|||||||
return output_path
|
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):
|
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)
|
ws.write(current_row, 1, "", fmt_np_rr)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
|
|
||||||
# 3. Официальные отсутствия (Сотрудники из исключений при наличии документа 1С попадают сюда)
|
# 3. Официальные отсутствия
|
||||||
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
|
||||||
is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
|
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)
|
ws.write(current_row, 1, len(group), fmt_cat_hr)
|
||||||
current_row += 1
|
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.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, 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
|
current_row += 1
|
||||||
|
|
||||||
# 4. Итого на работе (Только общее число, без раскрывающегося списка ФИО. Включает исключения без справок)
|
# 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()
|
first_act_val = str(row.get('Первая_активность', '—')).strip()
|
||||||
has_first_act = first_act_val not in ['—', '', 'None', 'nan']
|
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)
|
deviation_val = calculate_deviation(
|
||||||
else:
|
in_building_str,
|
||||||
deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
|
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('Подразделение', '')))
|
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]]:
|
def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
||||||
init_exceptions_table()
|
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:
|
with get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -37,7 +45,6 @@ def get_all_exceptions_from_db() -> Dict[str, List[str]]:
|
|||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
if not rows and os.path.exists(EXCEPTIONS_PATH):
|
if not rows and os.path.exists(EXCEPTIONS_PATH):
|
||||||
# Первичная миграция из JSON в SQLite
|
|
||||||
sync_json_to_db()
|
sync_json_to_db()
|
||||||
return get_all_exceptions_from_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:
|
def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
|
||||||
init_exceptions_table()
|
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:
|
if not val_clean:
|
||||||
return False
|
return False
|
||||||
with get_connection() as conn:
|
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":
|
if reason and reason.lower() != "nan":
|
||||||
absences_map[fio] = reason
|
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["fio_clean"].map(absences_map)
|
||||||
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
|
df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
|
||||||
|
|
||||||
|
|||||||
+96
-40
@@ -3,6 +3,7 @@
|
|||||||
FILE: services/scud_export.py
|
FILE: services/scud_export.py
|
||||||
ROLE: Прямой экспорт данных СКУД Орион (MS SQL) в SQLite и чистый Excel (XlsxWriter).
|
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 @TargetDate DATE = @InputDate;
|
||||||
|
|
||||||
DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
|
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
|
SELECT
|
||||||
log.HozOrgan AS EmployeeID,
|
log.HozOrgan AS EmployeeID,
|
||||||
log.TimeVal,
|
log.TimeVal,
|
||||||
log.Event,
|
|
||||||
log.Mode,
|
|
||||||
-- Приоритет отдается физическому направлению контроллера (Mode):
|
|
||||||
CASE
|
CASE
|
||||||
WHEN log.Mode = 2 THEN 'OUT'
|
|
||||||
WHEN log.Mode = 1 THEN 'IN'
|
WHEN log.Mode = 1 THEN 'IN'
|
||||||
WHEN log.Event IN (2, 27, 29, 33, 55, 65) THEN 'OUT'
|
WHEN log.Mode = 2 THEN 'OUT'
|
||||||
WHEN log.Event IN (1, 21, 26, 54, 64) THEN 'IN'
|
|
||||||
ELSE 'OTHER'
|
ELSE 'OTHER'
|
||||||
END AS Direction,
|
END AS Direction
|
||||||
-- Вычисляем самое последнее событие сотрудника за день:
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC, log.Event DESC) AS RnLast
|
|
||||||
FROM pLogData log WITH (NOLOCK)
|
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
|
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||||
AND log.HozOrgan IS NOT NULL
|
AND log.HozOrgan IS NOT NULL
|
||||||
AND log.HozOrgan > 0
|
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 (
|
Passages AS (
|
||||||
SELECT
|
SELECT
|
||||||
EmployeeID,
|
EmployeeID,
|
||||||
MIN(TimeVal) AS FirstRawEvent,
|
MIN(TimeVal) AS FirstRawEvent,
|
||||||
MAX(TimeVal) AS LastRawEvent,
|
MAX(TimeVal) AS LastRawEvent,
|
||||||
-- Первый вход за день:
|
|
||||||
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
|
||||||
-- Фиксируем выход ТОЛЬКО если самое последнее событие за день было именно выходом (OUT):
|
MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS FinalOut
|
||||||
MAX(CASE WHEN RnLast = 1 AND Direction = 'OUT' THEN TimeVal END) AS FinalOut
|
FROM PercoPassages
|
||||||
FROM DailyLogs
|
|
||||||
GROUP BY EmployeeID
|
GROUP BY EmployeeID
|
||||||
),
|
),
|
||||||
EvaluatedPassages AS (
|
EvaluatedPassages AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
-- Время выхода проставляется только при окончательном уходе сотрудника из здания:
|
|
||||||
CASE
|
CASE
|
||||||
WHEN p.FinalOut IS NOT NULL
|
WHEN p.FinalOut IS NOT NULL
|
||||||
AND p.FirstIn IS NOT NULL
|
AND p.FirstIn IS NOT NULL
|
||||||
@@ -157,15 +162,13 @@ SELECT
|
|||||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||||
CASE
|
CASE
|
||||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
|
ELSE @EndDate
|
||||||
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
|
||||||
END) / 60 AS VARCHAR), 2) + ':' +
|
END) / 60 AS VARCHAR), 2) + ':' +
|
||||||
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
RIGHT('0' + CAST(DATEDIFF(MINUTE,
|
||||||
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
ISNULL(pass.FirstIn, pass.FirstRawEvent),
|
||||||
CASE
|
CASE
|
||||||
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
WHEN pass.FilteredLastOut IS NOT NULL THEN pass.FilteredLastOut
|
||||||
WHEN @TargetDate = CAST(GETDATE() AS DATE) THEN GETDATE()
|
ELSE @EndDate
|
||||||
ELSE ISNULL(pass.FirstIn, pass.FirstRawEvent)
|
|
||||||
END) % 60 AS VARCHAR), 2)
|
END) % 60 AS VARCHAR), 2)
|
||||||
ELSE N'00:00'
|
ELSE N'00:00'
|
||||||
END AS [Находился_в_здании],
|
END AS [Находился_в_здании],
|
||||||
@@ -196,7 +199,7 @@ ORDER BY p.Name ASC;
|
|||||||
SQL_RAW_EVENTS_QUERY = r"""
|
SQL_RAW_EVENTS_QUERY = r"""
|
||||||
DECLARE @InputDate DATE = '{target_date}';
|
DECLARE @InputDate DATE = '{target_date}';
|
||||||
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
|
DECLARE @StartDate DATETIME = CAST(@InputDate AS DATETIME);
|
||||||
DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
|
DECLARE @EndDate DATETIME = {end_datetime_sql};
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
log.TimeVal,
|
log.TimeVal,
|
||||||
@@ -211,6 +214,7 @@ SELECT
|
|||||||
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
|
||||||
log.Event,
|
log.Event,
|
||||||
log.Mode,
|
log.Mode,
|
||||||
|
log.DoorIndex,
|
||||||
CASE
|
CASE
|
||||||
WHEN log.Mode = 2 THEN 'OUT'
|
WHEN log.Mode = 2 THEN 'OUT'
|
||||||
WHEN log.Mode = 1 THEN 'IN'
|
WHEN log.Mode = 1 THEN 'IN'
|
||||||
@@ -229,6 +233,7 @@ WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
|||||||
ORDER BY log.TimeVal ASC;
|
ORDER BY log.TimeVal ASC;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
|
def save_df_to_clean_excel(df: pd.DataFrame, file_path: str, sheet_name: str = "Отчет"):
|
||||||
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
|
workbook = xlsxwriter.Workbook(file_path, {'constant_memory': False})
|
||||||
worksheet = workbook.add_worksheet(sheet_name)
|
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()
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
def get_targets(input_date: str | None):
|
def get_targets(input_date: str | None, input_time: str | None = None):
|
||||||
targets = []
|
targets = []
|
||||||
if input_date:
|
if input_date:
|
||||||
try:
|
try:
|
||||||
parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
|
parsed = datetime.strptime(input_date.replace('_', '.'), "%d.%m.%Y").date()
|
||||||
targets.append({"name": "Указанная дата", "date": parsed})
|
targets.append({"name": "Указанная дата", "date": parsed, "target_time": input_time})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
|
log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
|
||||||
sys.exit(1)
|
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()
|
yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
targets.append({"name": "Вчера", "date": yesterday})
|
targets.append({"name": "Вчера", "date": yesterday, "target_time": None})
|
||||||
targets.append({"name": "Сегодня", "date": today})
|
targets.append({"name": "Сегодня", "date": today, "target_time": None})
|
||||||
|
|
||||||
return targets
|
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:
|
if debug:
|
||||||
logger.setLevel(logging.DEBUG)
|
logger.setLevel(logging.DEBUG)
|
||||||
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
|
log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
|
||||||
@@ -307,7 +312,7 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
|||||||
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
|
log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
|
||||||
os.makedirs(SCUD_DIR, exist_ok=True)
|
os.makedirs(SCUD_DIR, exist_ok=True)
|
||||||
|
|
||||||
targets = get_targets(input_date)
|
targets = get_targets(input_date, input_time)
|
||||||
conn_str = (
|
conn_str = (
|
||||||
f"DRIVER={{{ODBC_DRIVER}}};"
|
f"DRIVER={{{ODBC_DRIVER}}};"
|
||||||
f"SERVER={SERVER_NAME};"
|
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 = target["date"]
|
||||||
processing_date_str = processing_date.strftime("%d.%m.%Y")
|
processing_date_str = processing_date.strftime("%d.%m.%Y")
|
||||||
period_label = target["name"]
|
period_label = target["name"]
|
||||||
is_yesterday = (period_label == "Вчера")
|
target_time = target["target_time"]
|
||||||
|
|
||||||
if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
|
today_date = datetime.now().date()
|
||||||
log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
|
is_past_day = (processing_date < today_date) or (period_label == "Вчера")
|
||||||
|
|
||||||
|
if is_past_day and not target_time and has_yesterday_final_snapshot(processing_date_str):
|
||||||
|
log(f"[ℹ️] День ({processing_date_str}) уже зафиксирован финишным снапшотом _FINAL. Пропускаем.")
|
||||||
continue
|
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"
|
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:
|
else:
|
||||||
snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
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}]")
|
log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Срез: {snapshot_time}]")
|
||||||
sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
|
|
||||||
|
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
|
connection = None
|
||||||
try:
|
try:
|
||||||
@@ -353,14 +405,17 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
|||||||
|
|
||||||
df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
|
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)
|
df_raw = pd.read_sql(raw_sql, connection)
|
||||||
if len(df_raw) > 0:
|
if len(df_raw) > 0:
|
||||||
df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light)
|
df_raw['fio_clean'] = df_raw['Сотрудник'].apply(clean_scud_fio_light)
|
||||||
inserted_count = save_raw_events_to_db(df_raw, processing_date_str)
|
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")
|
||||||
|
|
||||||
@@ -392,9 +447,10 @@ def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: boo
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
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("-d", "--debug", action="store_true")
|
||||||
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
|
parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True)
|
||||||
args = parser.parse_args()
|
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
|
FILE: services/snapshots/service.py
|
||||||
PROJECT: SCUD Orion AI (Unified Architecture)
|
PROJECT: SCUD Orion AI (Unified Architecture)
|
||||||
MODULE: services / snapshots
|
MODULE: services / snapshots
|
||||||
ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
|
ROLE: Бизнес-логика срезов СКУД (выборка, отображение времени, удаление).
|
||||||
|
|
||||||
AI-CONTEXT-ANCHORS:
|
AI-CONTEXT-ANCHORS:
|
||||||
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
|
- ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
|
||||||
@@ -18,20 +18,39 @@ from core.repositories.scud_repo import get_available_snapshots, delete_snapshot
|
|||||||
|
|
||||||
# ANCHOR[SNAPSHOT_GET_REGISTRY]
|
# ANCHOR[SNAPSHOT_GET_REGISTRY]
|
||||||
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
|
def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""Возвращает реестр снапшотов за дату или за все доступные дни."""
|
"""
|
||||||
|
Возвращает реестр снапшотов.
|
||||||
|
В поле snapshot_time объединяет время среза и фактическое время создания снапшота.
|
||||||
|
"""
|
||||||
clean_date = date_str.strip() if date_str else ""
|
clean_date = date_str.strip() if date_str else ""
|
||||||
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
|
rows = get_available_snapshots(date_str=clean_date if clean_date else None)
|
||||||
|
|
||||||
snapshots = [
|
snapshots = []
|
||||||
{
|
for r in rows:
|
||||||
"snapshot_id": r[0],
|
snap_id = r[0]
|
||||||
"log_date": r[1],
|
log_date = r[1]
|
||||||
"snapshot_time": r[2],
|
snap_time = r[2]
|
||||||
"record_count": r[3],
|
rec_count = r[3]
|
||||||
"is_final": str(r[0]).startswith("Y")
|
created_at = r[4] if len(r) > 4 else None
|
||||||
}
|
|
||||||
for r in rows
|
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 {
|
return {
|
||||||
"query_date": clean_date or "все",
|
"query_date": clean_date or "все",
|
||||||
|
|||||||
+2292
-511
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user