feat(reports): stabilize on-demand generation, live presence and 1C fallback
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
===============================================================================
|
||||
FILE: services/scud_etl/pipeline.py
|
||||
ROLE: Загрузка наилучших срезов СКУД и штата/отсутствий 1С с умным fallback-ом.
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
@@ -12,7 +13,7 @@ import pandas as pd
|
||||
from core.connection import get_connection
|
||||
from core.database import load_scud_from_db_by_snapshot
|
||||
from config import DATA_DIR
|
||||
from services.data_loader import load_staff_data, load_absent_data
|
||||
from services.data_loader import load_1c_data_smart, load_staff_data, load_absent_data
|
||||
|
||||
logger = logging.getLogger("SCUD_PIPELINE")
|
||||
|
||||
@@ -57,8 +58,42 @@ def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = False) ->
|
||||
|
||||
def load_1c_files_for_date(date_str: str) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
|
||||
"""
|
||||
Загружает реестры штата и отсутствий 1С на указанную дату через data_loader.
|
||||
Загружает штат и отсутствия с каскадным fallback:
|
||||
1. Синхронизирует свежие файлы с сетевой шары.
|
||||
2. Загружает штат и отсутствия через load_1c_data_smart(..., use_db=True).
|
||||
3. Если штат пуст — берет последний доступный срез штата из zup_staff в SQLite.
|
||||
"""
|
||||
df_staff = load_staff_data(date_str)
|
||||
df_abs = load_absent_data(date_str)
|
||||
clean_date = date_str.replace('_', '.')
|
||||
|
||||
try:
|
||||
from services.share_copier import copy_1c_files_from_share
|
||||
copy_1c_files_from_share()
|
||||
except Exception as e:
|
||||
logger.warning(f"[Pipeline] Ошибка копирования с шары: {e}")
|
||||
|
||||
df_staff, df_abs = load_1c_data_smart(clean_date, use_db=True)
|
||||
|
||||
# Fallback за штат: ако данашњи штат још увек није доступан, користи се претходни из базе
|
||||
if df_staff is None or df_staff.empty:
|
||||
with get_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT snapshot_date
|
||||
FROM zup_staff
|
||||
ORDER BY
|
||||
SUBSTR(snapshot_date, 7, 4) DESC,
|
||||
SUBSTR(snapshot_date, 4, 2) DESC,
|
||||
SUBSTR(snapshot_date, 1, 2) DESC,
|
||||
id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
fallback_date = row[0]
|
||||
df_staff = pd.read_sql_query(
|
||||
"SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
|
||||
conn, params=(fallback_date,)
|
||||
)
|
||||
logger.info(f"[Pipeline] Для даты {clean_date} применен штат за {fallback_date} ({len(df_staff)} чел.)")
|
||||
|
||||
return df_staff, df_abs
|
||||
@@ -5,7 +5,7 @@ ROLE: Изолированные SQL-шаблоны для MS SQL Server (СКУ
|
||||
===============================================================================
|
||||
"""
|
||||
|
||||
# 1. Основной срез СКУД с учетом двухконтурного правого турникета
|
||||
# 1. Основной срез СКУД с учетом турникетов и флигеля
|
||||
SQL_QUERY_TEMPLATE = r"""
|
||||
DECLARE @InputDate DATE = '{target_date}';
|
||||
DECLARE @TargetDate DATE = @InputDate;
|
||||
@@ -40,6 +40,12 @@ WITH PercoPassages AS (
|
||||
log.DoorIndex = 2
|
||||
AND ({turnstile_filter_sql})
|
||||
)
|
||||
OR
|
||||
-- Контур 3: Флигель 1 эт. разрешен только для реестра флигеля
|
||||
(
|
||||
log.DoorIndex = 23
|
||||
AND ({fligel_filter_sql})
|
||||
)
|
||||
)
|
||||
),
|
||||
Passages AS (
|
||||
@@ -158,10 +164,23 @@ SELECT
|
||||
FROM pLogData log WITH (NOLOCK)
|
||||
INNER JOIN pList p WITH (NOLOCK) ON log.HozOrgan = p.ID
|
||||
LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
|
||||
LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
|
||||
WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
|
||||
AND log.HozOrgan IS NOT NULL
|
||||
AND log.HozOrgan > 0
|
||||
AND log.Event IN (28, 32)
|
||||
-- ⭐️ Фильтрация сотрудников Ленморниипроект (исключение арендаторов и гостей)
|
||||
AND ISNULL(p.StatusRecord, 0) = 0
|
||||
AND p.DateTimeInArchive IS NULL
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
|
||||
AND p.Name NOT LIKE N'бр.%'
|
||||
AND p.Name NOT LIKE N'Гость%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
|
||||
AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
|
||||
AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
|
||||
ORDER BY log.TimeVal ASC;
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ import pandas as pd
|
||||
|
||||
from config import DATE_TODAY
|
||||
from core.database import load_scud_from_db_by_snapshot
|
||||
from services.scud_etl.pipeline import load_1c_files_for_date
|
||||
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
|
||||
from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
|
||||
from services.scud_etl.anomaly_detector import detect_registry_anomalies
|
||||
from services.snapshots.finder import find_or_create_snapshot_for_time
|
||||
@@ -27,37 +27,46 @@ def generate_svodka_service(
|
||||
snapshot_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Формирует оперативную сводку на указанную дату / время:
|
||||
- target_date: дата сводки (по умолчанию сегодня).
|
||||
- target_time: время среза (например '14:30').
|
||||
- snapshot_id: точный ID среза.
|
||||
Формирует оперативную сводку:
|
||||
- По умолчанию берет ПОСЛЕДНИЙ готовый снапшот из базы SQLite (без долгого опроса MS SQL).
|
||||
"""
|
||||
date_clean = str(target_date or DATE_TODAY).replace('_', '.')
|
||||
applied_note = ""
|
||||
|
||||
# Если передано время, но не указан конкретный snapshot_id — ищем ближайший или запрашиваем экспорт
|
||||
if target_time and not snapshot_id:
|
||||
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=True)
|
||||
snapshot_id = found_id
|
||||
# 1. Загрузка среза СКУД строго из готовых в базе
|
||||
if snapshot_id:
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||
elif target_time:
|
||||
found_id, note = find_or_create_snapshot_for_time(date_clean, target_time, allow_ondemand_export=False)
|
||||
applied_note = note
|
||||
if note:
|
||||
logger.info(note)
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=found_id)
|
||||
else:
|
||||
# По умолчанию: берем самый свежий существующий срез за дату
|
||||
df_scud = load_best_snapshot_for_date(date_clean, prefer_final_y=False)
|
||||
|
||||
df_scud = load_scud_from_db_by_snapshot(date_clean, snapshot_param=snapshot_id)
|
||||
if df_scud is None or df_scud.empty:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Срез СКУД за {date_clean} ({applied_note or snapshot_id or 'последний доступный'}) не найден в базе."
|
||||
"message": f"Срез СКУД за {date_clean} не найден в базе данных."
|
||||
}
|
||||
|
||||
# Извлекаем время фактического среза для имени файла
|
||||
actual_snap_time = ""
|
||||
if 'snapshot_time' in df_scud.columns and not df_scud.empty:
|
||||
raw_st = str(df_scud['snapshot_time'].iloc[0]).strip()
|
||||
if " " in raw_st:
|
||||
actual_snap_time = raw_st.split()[1][:5].replace(':', '-')
|
||||
|
||||
# 2. Загружаем актуальные кадровые данные 1С (штат + отсутствия + реестры)
|
||||
df_staff, df_abs = load_1c_files_for_date(date_clean)
|
||||
|
||||
# 3. Слияние и расчет
|
||||
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
|
||||
metrics = calculate_summary_metrics(df_merged)
|
||||
anomalies = detect_registry_anomalies(df_merged, df_raw_scud=df_scud)
|
||||
|
||||
# Добавляем суффикс времени в имя файла, если сводка строилась на точный срез
|
||||
time_suffix = f" на {target_time.replace(':', '-')}" if target_time else ""
|
||||
# 4. Формирование книги Excel
|
||||
time_suffix = f" на {actual_snap_time}" if actual_snap_time else ""
|
||||
filename = f"{format_date_ru(date_clean)} сводка{time_suffix}.xlsx"
|
||||
generate_summary_excel(df_merged, date_str=date_clean, filename=filename)
|
||||
|
||||
@@ -68,13 +77,13 @@ def generate_svodka_service(
|
||||
"status": "success",
|
||||
"report_type": "SVODKA",
|
||||
"date": date_clean,
|
||||
"target_time": target_time,
|
||||
"snapshot_id": snapshot_id or "AUTO_LATEST",
|
||||
"target_time": actual_snap_time,
|
||||
"snapshot_id": snapshot_id or "LATEST_READY",
|
||||
"filename": filename,
|
||||
"filepath": full_filepath,
|
||||
"download_url": f"/api/v1/files/download/reports/{os.path.basename(full_filepath)}",
|
||||
"metrics": metrics,
|
||||
"anomalies_count": len(anomalies),
|
||||
"note": applied_note,
|
||||
"message": f"Ежедневная сводка на {date_clean} {target_time or ''} успешно сформирована."
|
||||
"message": f"Ежедневная сводка на {date_clean} успешно сформирована."
|
||||
}
|
||||
Reference in New Issue
Block a user