diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..787b5db --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python -c \":*)" + ] + } +} diff --git a/.env b/.env index e2da528..19aefcc 100644 --- a/.env +++ b/.env @@ -10,3 +10,10 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=changeme ADMIN_HOST=0.0.0.0 ADMIN_PORT=8080 + +# Intradesk API +INTRADESK_BASE_URL=https://apigw.intradesk.ru +INTRADESK_API_KEY=4842AA91805F4630853EF42D74 +INTRADESK_TASKS_API_KEY=26221B839B6643E68345FD26BF +INTRADESK_EXECUTOR_GROUP=243025 +INTRADESK_EXECUTORS=2227907,525111,631481,2222953 diff --git a/.env.example b/.env.example index 20b2c23..3a5964c 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,10 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=changeme ADMIN_HOST=0.0.0.0 ADMIN_PORT=8080 + +# Intradesk API +INTRADESK_BASE_URL=https://apigw.intradesk.ru +INTRADESK_API_KEY=your_settings_api_key +INTRADESK_TASKS_API_KEY=your_tasks_api_key +INTRADESK_EXECUTOR_GROUP=0 +INTRADESK_EXECUTORS=id1,id2,id3 diff --git a/admin/app.py b/admin/app.py index 5e2bc35..00c4152 100644 --- a/admin/app.py +++ b/admin/app.py @@ -1,7 +1,15 @@ from fastapi import FastAPI from fastapi.responses import RedirectResponse -from admin.routes import organizations, org_users, bot_users, tickets, chat_log +from admin.routes import ( + bot_users, + chat_log, + intradesk_clients, + organizations, + org_users, + sync, + tickets, +) def create_admin_app() -> FastAPI: @@ -16,5 +24,7 @@ def create_admin_app() -> FastAPI: app.include_router(bot_users.router, prefix="/admin") app.include_router(tickets.router, prefix="/admin") app.include_router(chat_log.router, prefix="/admin") + app.include_router(sync.router, prefix="/admin") + app.include_router(intradesk_clients.router, prefix="/admin") return app diff --git a/admin/routes/intradesk_clients.py b/admin/routes/intradesk_clients.py new file mode 100644 index 0000000..f58434a --- /dev/null +++ b/admin/routes/intradesk_clients.py @@ -0,0 +1,84 @@ +""" +Роуты просмотра синхронизированных данных из Intradesk. + +GET /admin/intradesk/clients — список клиентов +GET /admin/intradesk/clients/{client_id}/tasks — заявки клиента +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from admin.auth import require_admin +from admin.templates_env import templates +from database.connection import get_db +from database.queries.intradesk import ( + count_tasks_for_client, + get_all_clients, + get_client, + get_distinct_statuses, + get_tasks_for_client, + get_users_for_client, +) + +router = APIRouter() + +_PAGE_SIZE = 30 + + +@router.get("/intradesk/clients", dependencies=[Depends(require_admin)]) +async def clients_list(request: Request, search: str = ""): + db = get_db() + clients = await get_all_clients(db, search=search) + return templates.TemplateResponse( + "intradesk/clients.html", + {"request": request, "clients": clients, "search": search}, + ) + + +@router.get( + "/intradesk/clients/{client_id}/tasks", + dependencies=[Depends(require_admin)], +) +async def client_tasks( + request: Request, + client_id: int, + status: str = "", + page: int = 0, +): + db = get_db() + client = await get_client(db, client_id) + if client is None: + return templates.TemplateResponse( + "intradesk/clients.html", + { + "request": request, + "clients": [], + "search": "", + "error": f"Клиент {client_id} не найден", + }, + ) + + tasks = await get_tasks_for_client( + db, client_id, status_filter=status, page=page, page_size=_PAGE_SIZE + ) + total = await count_tasks_for_client(db, client_id, status_filter=status) + statuses = await get_distinct_statuses(db) + users = await get_users_for_client(db, client_id) + + pages = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE) + + return templates.TemplateResponse( + "intradesk/client_tasks.html", + { + "request": request, + "client": client, + "tasks": tasks, + "users": users, + "total": total, + "page": page, + "pages": pages, + "page_size": _PAGE_SIZE, + "status": status, + "statuses": statuses, + }, + ) diff --git a/admin/routes/sync.py b/admin/routes/sync.py new file mode 100644 index 0000000..dc12cb3 --- /dev/null +++ b/admin/routes/sync.py @@ -0,0 +1,53 @@ +""" +Роуты страницы синхронизации с Intradesk. + +GET /admin/sync — страница с кнопками и историей +POST /admin/sync/run — запустить синхронизацию (sync_type в форме) +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import RedirectResponse + +from admin.auth import require_admin +from admin.templates_env import templates +from database.connection import get_db +from database.queries.intradesk import get_last_sync, get_recent_sync_logs +from external.intradesk_sync import run_sync + +router = APIRouter() + +_SYNC_LABELS = { + "clients": "Клиенты / организации", + "users": "Пользователи (контактные лица)", + "tasks": "Заявки", + "all": "Всё сразу", +} + + +@router.get("/sync", dependencies=[Depends(require_admin)]) +async def sync_page(request: Request): + db = get_db() + logs = await get_recent_sync_logs(db, limit=20) + + last: dict[str, dict | None] = {} + for k in ("clients", "users", "tasks"): + last[k] = await get_last_sync(db, k) + + return templates.TemplateResponse( + "sync/index.html", + { + "request": request, + "logs": logs, + "last": last, + "sync_labels": _SYNC_LABELS, + }, + ) + + +@router.post("/sync/run", dependencies=[Depends(require_admin)]) +async def sync_run(sync_type: str = Form(...)): + if sync_type not in _SYNC_LABELS: + return RedirectResponse("/admin/sync", status_code=303) + await run_sync(sync_type) + return RedirectResponse("/admin/sync", status_code=303) diff --git a/admin/templates/base.html b/admin/templates/base.html index ca0cd63..aa5673b 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -52,10 +52,13 @@
{% block content %}{% endblock %} diff --git a/admin/templates/intradesk/client_tasks.html b/admin/templates/intradesk/client_tasks.html new file mode 100644 index 0000000..ac93a1d --- /dev/null +++ b/admin/templates/intradesk/client_tasks.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} +{% block title %}Заявки — {{ client.name }} — Support Admin{% endblock %} +{% block content %} + +{# ── Шапка клиента ── #} +
+ ← Назад +

{{ client.name }}

+ {% if client.client_type == 10 %} + Компания + {% elif client.client_type == 20 %} + Физлицо + {% endif %} +
+ +{# ── Краткая инфо о клиенте ── #} +{% if client.email or client.address or client.description %} +
+
+ {% if client.email %}
📧 {{ client.email }}
{% endif %} + {% if client.address %}
📍 {{ client.address }}
{% endif %} + {% if client.description %} +
{{ client.description }}
+ {% endif %} +
+
+{% endif %} + +{# ── Контактные лица ── #} +{% if users %} +
+

Контактные лица ({{ users | length }})

+ + + + {% for u in users %} + + + + + + {% endfor %} + +
ИмяEmailТелефон
{{ u.name or (u.last_name ~ ' ' ~ u.first_name ~ ' ' ~ u.middle_name)|trim }}{{ u.email or '—' }}{{ u.phone or '—' }}
+
+{% endif %} + +{# ── Фильтр заявок ── #} +
+
+
+ + + + {% if status %} + Сбросить + {% endif %} +
+ + Всего заявок: {{ total }} + +
+ + {# ── Таблица заявок ── #} + {% if tasks %} + + + + + + + + + + + + + + + {% for t in tasks %} + + + + + + + + + + + {% if t.description %} + + + + + {% endif %} + {% endfor %} + +
#ТемаСтатусПриоритетСервисИсполнительСозданаОбновлена
{{ t.task_number or t.id }} + + {{ t.name[:80] }}{% if t.name | length > 80 %}…{% endif %} + + + {% set sn = t.status_name | lower %} + {% if 'выполнен' in sn %} + {{ t.status_name }} + {% elif 'работ' in sn %} + {{ t.status_name }} + {% elif 'открыт' in sn %} + {{ t.status_name }} + {% else %} + {{ t.status_name or '—' }} + {% endif %} + + {% set pn = t.priority_name | lower %} + {% if 'критич' in pn %}🔴{% elif 'высок' in pn %}🟠{% elif 'средн' in pn %}🟡{% else %}⚪{% endif %} + {{ t.priority_name or '—' }} + + {{ t.service_name or '—' }} + {{ t.executor_name or '—' }} + {{ (t.created_at or '')[:10] or '—' }} + + {{ (t.updated_at or '')[:10] or '—' }} +
+ {{ t.description | striptags | truncate(200) }} +
+ + {# ── Пагинация ── #} + {% if pages > 1 %} +
+ {% if page > 0 %} + ← Назад + {% endif %} + + Стр. {{ page + 1 }} из {{ pages }} + + {% if page < pages - 1 %} + Вперёд → + {% endif %} +
+ {% endif %} + + {% else %} +

+ {% if status %}Нет заявок со статусом «{{ status }}».{% else %}Нет заявок.{% endif %} +

+ {% endif %} +
+{% endblock %} diff --git a/admin/templates/intradesk/clients.html b/admin/templates/intradesk/clients.html new file mode 100644 index 0000000..5f796a7 --- /dev/null +++ b/admin/templates/intradesk/clients.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Клиенты Intradesk — Support Admin{% endblock %} +{% block content %} +

🏢 Клиенты Intradesk

+ +{% if error %} +
+ {{ error }} +
+{% endif %} + +
+
+
+ + + {% if search %} + Сбросить + {% endif %} +
+ 🔄 Синхронизация +
+ + {% if clients %} + + + + + + + + + + + + + + {% for c in clients %} + + + + + + + + + + {% endfor %} + +
IDНазваниеТипEmailАдресОбновлён
{{ c.id }}{{ c.name }} + {% if c.client_type == 10 %} + Компания + {% elif c.client_type == 20 %} + Физлицо + {% else %} + + {% endif %} + {{ c.email or '—' }} + {{ c.address or '—' }} + + {{ (c.updated_at or '')[:10] or '—' }} + + + 📋 Заявки + +
+

+ Всего: {{ clients | length }} + {% if search %}(по фильтру){% endif %} +

+ {% else %} +

+ {% if search %}Нет клиентов, соответствующих «{{ search }}».{% else %} + Нет данных. Запустите синхронизацию.{% endif %} +

+ {% endif %} +
+{% endblock %} diff --git a/admin/templates/sync/index.html b/admin/templates/sync/index.html new file mode 100644 index 0000000..0efe408 --- /dev/null +++ b/admin/templates/sync/index.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} +{% block title %}Синхронизация — Support Admin{% endblock %} +{% block content %} +

🔄 Синхронизация с Intradesk

+ +{# ── Кнопки запуска ── #} +
+

Запустить синхронизацию

+

+ Данные загружаются из Intradesk API и сохраняются локально. + Синхронизация выполняется синхронно — страница обновится по завершении. +

+
+ {% for key, label in sync_labels.items() %} +
+ + +
+ {% endfor %} +
+
+ +{# ── Последняя успешная синхронизация ── #} +
+

Последняя успешная синхронизация

+ + + + + + {% for key, label in sync_labels.items() if key != 'all' %} + + + {% set rec = last[key] %} + {% if rec %} + + + {% else %} + + {% endif %} + + {% endfor %} + +
ТипДатаЗаписей
{{ label }}{{ rec.finished_at or rec.started_at }}{{ rec.records_synced }}Не выполнялась
+
+ +{# ── История запусков ── #} +
+

История запусков (последние 20)

+ {% if logs %} + + + + + + + + + {% for log in logs %} + + + + + + + + + + {% endfor %} + +
#ТипНачалоКонецСтатусЗаписейОшибка
{{ log.id }}{{ sync_labels.get(log.sync_type, log.sync_type) }}{{ log.started_at }}{{ log.finished_at or '—' }} + {% if log.status == 'success' %} + ✓ Успешно + {% elif log.status == 'error' %} + ✗ Ошибка + {% else %} + ⟳ Выполняется + {% endif %} + {{ log.records_synced }} + {{ log.error_message or '' }} +
+ {% else %} +

Синхронизаций ещё не было.

+ {% endif %} +
+{% endblock %} diff --git a/config.py b/config.py index f9d370d..95a12d1 100644 --- a/config.py +++ b/config.py @@ -15,3 +15,18 @@ ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin") ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "changeme") ADMIN_HOST: str = os.environ.get("ADMIN_HOST", "0.0.0.0") ADMIN_PORT: int = int(os.environ.get("ADMIN_PORT", "8080")) + +# ── Intradesk API ────────────────────────────────────────────────────────────── +INTRADESK_BASE_URL: str = os.environ.get("INTRADESK_BASE_URL", "https://apigw.intradesk.ru") +# Ключ для /settings/odata (клиенты, пользователи) +INTRADESK_API_KEY: str = os.environ.get("INTRADESK_API_KEY", "") +# Ключ для /tasklist/odata (заявки) +INTRADESK_TASKS_API_KEY: str = os.environ.get("INTRADESK_TASKS_API_KEY", "") +# ID группы исполнителей для фильтрации заявок +INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "") +# Список ID исполнителей через запятую (доп. фильтр) +INTRADESK_EXECUTORS: list[str] = [ + e.strip() + for e in os.environ.get("INTRADESK_EXECUTORS", "").split(",") + if e.strip() +] diff --git a/data/support.db b/data/support.db index 0de02ec..27402e1 100644 Binary files a/data/support.db and b/data/support.db differ diff --git a/data/support.db-shm b/data/support.db-shm index 7e7a31a..5d200f0 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index f45ce7a..755161b 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/database/connection.py b/database/connection.py index 1e20c75..e9dbc66 100644 --- a/database/connection.py +++ b/database/connection.py @@ -24,6 +24,18 @@ async def init_db() -> None: await _db.execute(stmt) await _db.commit() + # ── Миграции для существующих БД ───────────────────────────────────────── + # Добавляем intradesk_id в organizations, если ещё нет + try: + await _db.execute("ALTER TABLE organizations ADD COLUMN intradesk_id INTEGER") + await _db.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_orgs_intradesk_id " + "ON organizations(intradesk_id)" + ) + await _db.commit() + except Exception: + pass # колонка уже существует + def get_db() -> aiosqlite.Connection: if _db is None: diff --git a/database/queries/intradesk.py b/database/queries/intradesk.py new file mode 100644 index 0000000..ce2be00 --- /dev/null +++ b/database/queries/intradesk.py @@ -0,0 +1,375 @@ +"""Запросы к таблицам intradesk_* (синхронизированные справочники).""" +from __future__ import annotations + +import re +import aiosqlite + + +# ── Clients ─────────────────────────────────────────────────────────────────── + +async def upsert_client(db: aiosqlite.Connection, item: dict) -> None: + await db.execute( + """ + INSERT INTO intradesk_clients + (id, name, client_type, email, address, description, updated_at, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + client_type = excluded.client_type, + email = excluded.email, + address = excluded.address, + description = excluded.description, + updated_at = excluded.updated_at, + synced_at = excluded.synced_at + """, + ( + item["id"], + item.get("name") or "", + item.get("clientType"), + item.get("email"), + item.get("address"), + item.get("description"), + item.get("updatedAt"), + ), + ) + + +async def get_all_clients(db: aiosqlite.Connection, search: str = "") -> list[dict]: + q = "SELECT * FROM intradesk_clients" + params: list = [] + if search: + q += " WHERE name LIKE ?" + params.append(f"%{search}%") + q += " ORDER BY name" + async with db.execute(q, params) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def get_client(db: aiosqlite.Connection, client_id: int) -> dict | None: + async with db.execute( + "SELECT * FROM intradesk_clients WHERE id = ?", (client_id,) + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +# ── Users ───────────────────────────────────────────────────────────────────── + +async def upsert_user(db: aiosqlite.Connection, item: dict) -> None: + # Основная группа — первая в списке groups + client_id: int | None = None + groups = item.get("groups") or [] + if groups: + client_id = groups[0].get("userGroupId") + + # Телефон — первый из списка + phones = item.get("phoneNumbers") or [] + phone = phones[0].get("phoneNumber") if phones else item.get("phoneNumber") + + await db.execute( + """ + INSERT INTO intradesk_users + (id, name, first_name, last_name, middle_name, email, phone, client_id, is_archived, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + first_name = excluded.first_name, + last_name = excluded.last_name, + middle_name = excluded.middle_name, + email = excluded.email, + phone = excluded.phone, + client_id = excluded.client_id, + is_archived = excluded.is_archived, + synced_at = excluded.synced_at + """, + ( + item["id"], + item.get("name") or "", + item.get("firstName") or "", + item.get("lastName") or "", + item.get("middleName") or "", + item.get("email") or "", + phone, + client_id, + 1 if item.get("isArchived") else 0, + ), + ) + + +async def get_users_for_client( + db: aiosqlite.Connection, client_id: int +) -> list[dict]: + async with db.execute( + "SELECT * FROM intradesk_users WHERE client_id = ? ORDER BY name", + (client_id,), + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +# ── Tasks ───────────────────────────────────────────────────────────────────── + +async def upsert_task( + db: aiosqlite.Connection, item: dict, dicts: dict[int, str] | None = None +) -> None: + dicts = dicts or {} + + status_id = item.get("status") + status_name = dicts.get(status_id, "") if status_id else "" + + priority_id = item.get("priority") + priority_name = dicts.get(priority_id, "") if priority_id else "" + + executor_id = item.get("executor") + executor_name = dicts.get(executor_id, "") if executor_id else "" + + initiator_id = item.get("initiator") + initiator_name = dicts.get(initiator_id, "") if initiator_id else "" + + await db.execute( + """ + INSERT INTO intradesk_tasks ( + id, task_number, name, description, + status_id, status_name, + priority_id, priority_name, + client_id, initiator_id, initiator_name, + executor_id, executor_name, executor_group, + service_name, service_full_name, + created_at, updated_at, closed_at, created_by, updated_by, + synced_at + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) + ON CONFLICT(id) DO UPDATE SET + task_number = excluded.task_number, + name = excluded.name, + description = excluded.description, + status_id = excluded.status_id, + status_name = excluded.status_name, + priority_id = excluded.priority_id, + priority_name = excluded.priority_name, + client_id = excluded.client_id, + initiator_id = excluded.initiator_id, + initiator_name = excluded.initiator_name, + executor_id = excluded.executor_id, + executor_name = excluded.executor_name, + executor_group = excluded.executor_group, + service_name = excluded.service_name, + service_full_name = excluded.service_full_name, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + closed_at = excluded.closed_at, + created_by = excluded.created_by, + updated_by = excluded.updated_by, + synced_at = excluded.synced_at + """, + ( + item["id"], + item.get("tasknumber"), + item.get("name") or "", + item.get("description") or "", + status_id, + status_name, + priority_id, + priority_name, + item.get("clientid"), + initiator_id, + initiator_name, + executor_id, + executor_name, + item.get("executorgroup"), + item.get("servicename") or "", + item.get("servicefullname") or "", + item.get("createdat"), + item.get("updatedat"), + item.get("closedat"), + item.get("createdby") or "", + item.get("updatedby") or "", + ), + ) + + +async def get_tasks_for_client( + db: aiosqlite.Connection, + client_id: int, + status_filter: str = "", + page: int = 0, + page_size: int = 30, +) -> list[dict]: + q = "SELECT * FROM intradesk_tasks WHERE client_id = ?" + params: list = [client_id] + if status_filter: + q += " AND status_name = ?" + params.append(status_filter) + q += " ORDER BY updated_at DESC LIMIT ? OFFSET ?" + params += [page_size, page * page_size] + async with db.execute(q, params) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def count_tasks_for_client( + db: aiosqlite.Connection, client_id: int, status_filter: str = "" +) -> int: + q = "SELECT COUNT(*) FROM intradesk_tasks WHERE client_id = ?" + params: list = [client_id] + if status_filter: + q += " AND status_name = ?" + params.append(status_filter) + async with db.execute(q, params) as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + +async def get_distinct_statuses(db: aiosqlite.Connection) -> list[str]: + async with db.execute( + "SELECT DISTINCT status_name FROM intradesk_tasks WHERE status_name != '' ORDER BY status_name" + ) as cur: + rows = await cur.fetchall() + return [r[0] for r in rows] + + +# ── Sync log ────────────────────────────────────────────────────────────────── + +async def start_sync_log(db: aiosqlite.Connection, sync_type: str) -> int: + async with db.execute( + "INSERT INTO intradesk_sync_log (sync_type, status) VALUES (?, 'running')", + (sync_type,), + ) as cur: + log_id = cur.lastrowid + await db.commit() + return log_id # type: ignore[return-value] + + +async def finish_sync_log( + db: aiosqlite.Connection, + log_id: int, + status: str, + records_synced: int, + error_message: str = "", +) -> None: + await db.execute( + """ + UPDATE intradesk_sync_log + SET status = ?, records_synced = ?, error_message = ?, + finished_at = datetime('now') + WHERE id = ? + """, + (status, records_synced, error_message or None, log_id), + ) + await db.commit() + + +async def get_recent_sync_logs( + db: aiosqlite.Connection, limit: int = 10 +) -> list[dict]: + async with db.execute( + "SELECT * FROM intradesk_sync_log ORDER BY id DESC LIMIT ?", (limit,) + ) as cur: + rows = await cur.fetchall() + return [dict(r) for r in rows] + + +async def get_last_sync( + db: aiosqlite.Connection, sync_type: str +) -> dict | None: + async with db.execute( + """ + SELECT * FROM intradesk_sync_log + WHERE sync_type = ? AND status = 'success' + ORDER BY id DESC LIMIT 1 + """, + (sync_type,), + ) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + +# ── Whitelist sync ──────────────────────────────────────────────────────────── + +def _normalize_phone(raw: str) -> str: + """Убираем всё кроме цифр, заменяем ведущую 8 → 7 (как в боте).""" + digits = re.sub(r"\D", "", raw or "") + if digits.startswith("8") and len(digits) == 11: + digits = "7" + digits[1:] + return digits + + +async def sync_whitelist_from_intradesk(db: aiosqlite.Connection) -> int: + """ + Наполнить таблицы organizations и org_users данными из intradesk_clients / + intradesk_users. Вызывается автоматически после синхронизации. + + Возвращает количество затронутых строк в org_users. + """ + # 1. Загружаем существующий маппинг intradesk_id → local org_id + org_map: dict[int, int] = {} + async with db.execute( + "SELECT id, intradesk_id FROM organizations WHERE intradesk_id IS NOT NULL" + ) as cur: + for row in await cur.fetchall(): + org_map[row[1]] = row[0] + + # 2. Для каждого intradesk_client создаём или обновляем organizations + async with db.execute("SELECT id, name FROM intradesk_clients") as cur: + clients = await cur.fetchall() + + for c in clients: + iid, cname = c[0], c[1] + if iid in org_map: + # Обновляем имя на случай переименования в Intradesk + await db.execute( + "UPDATE organizations SET name = ? WHERE intradesk_id = ?", + (cname, iid), + ) + else: + # Создаём новую организацию + async with db.execute( + "INSERT INTO organizations (name, intradesk_id) VALUES (?, ?)", + (cname, iid), + ) as cur: + org_map[iid] = cur.lastrowid # type: ignore[assignment] + + await db.commit() + + # 3. Для каждого активного intradesk_user с телефоном — upsert в org_users + async with db.execute( + """ + SELECT id, phone, client_id, name, first_name, last_name + FROM intradesk_users + WHERE phone IS NOT NULL AND phone != '' AND is_archived = 0 + """ + ) as cur: + users = await cur.fetchall() + + count = 0 + for u in users: + raw_phone = u[1] or "" + phone = _normalize_phone(raw_phone) + if len(phone) < 7: + continue # явно некорректный номер + + client_id = u[2] + org_id = org_map.get(client_id) if client_id else None + if org_id is None: + continue # клиент ещё не синхронизирован + + # Формируем отображаемое имя + display = (u[3] or "").strip() or ( + f"{u[5] or ''} {u[4] or ''}".strip() or None + ) + + await db.execute( + """ + INSERT INTO org_users (org_id, phone, display_name) + VALUES (?, ?, ?) + ON CONFLICT(phone) DO UPDATE SET + org_id = excluded.org_id, + display_name = COALESCE(excluded.display_name, org_users.display_name) + """, + (org_id, phone, display), + ) + count += 1 + + await db.commit() + return count diff --git a/database/schema.sql b/database/schema.sql index c80ace0..ba23c71 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -2,9 +2,10 @@ PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; CREATE TABLE IF NOT EXISTS organizations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + intradesk_id INTEGER UNIQUE, -- id из intradesk_clients (NULL = ручное создание) + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Белый список телефонов по организациям. @@ -64,3 +65,68 @@ CREATE TABLE IF NOT EXISTS message_log ( attachment_json TEXT, -- JSON-строка списка вложений created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +-- ── Intradesk: синхронизируемые справочники ──────────────────────────────── + +-- Клиенты/организации из Intradesk (clientType 10=компания, 20=физлицо) +CREATE TABLE IF NOT EXISTS intradesk_clients ( + id INTEGER PRIMARY KEY, -- id из Intradesk + name TEXT NOT NULL, + client_type INTEGER, -- 10 / 20 + email TEXT, + address TEXT, + description TEXT, + updated_at TEXT, + synced_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Пользователи (контактные лица) клиентов из Intradesk +CREATE TABLE IF NOT EXISTS intradesk_users ( + id INTEGER PRIMARY KEY, -- id (contactPersonId) из Intradesk + name TEXT, + first_name TEXT, + last_name TEXT, + middle_name TEXT, + email TEXT, + phone TEXT, + client_id INTEGER, -- userGroup.id из Intradesk + is_archived INTEGER NOT NULL DEFAULT 0, + synced_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Заявки из Intradesk +CREATE TABLE IF NOT EXISTS intradesk_tasks ( + id INTEGER PRIMARY KEY, -- id из Intradesk + task_number INTEGER, + name TEXT, + description TEXT, + status_id INTEGER, + status_name TEXT, + priority_id INTEGER, + priority_name TEXT, + client_id INTEGER, -- clientid из Intradesk + initiator_id INTEGER, + initiator_name TEXT, + executor_id INTEGER, + executor_name TEXT, + executor_group INTEGER, + service_name TEXT, + service_full_name TEXT, + created_at TEXT, + updated_at TEXT, + closed_at TEXT, + created_by TEXT, + updated_by TEXT, + synced_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Лог запусков синхронизации +CREATE TABLE IF NOT EXISTS intradesk_sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sync_type TEXT NOT NULL, -- clients / users / tasks / all + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT, + status TEXT NOT NULL DEFAULT 'running', -- running / success / error + records_synced INTEGER NOT NULL DEFAULT 0, + error_message TEXT +); diff --git a/external/intradesk_sync.py b/external/intradesk_sync.py new file mode 100644 index 0000000..fce82a4 --- /dev/null +++ b/external/intradesk_sync.py @@ -0,0 +1,215 @@ +""" +Сервис синхронизации данных из Intradesk API. + +Поддерживаемые типы синхронизации: + - clients — организации/клиенты (/settings/odata/v2/Clients) + - users — контактные лица (/settings/odata/ClientUsers) + - tasks — заявки (/tasklist/odata/v3/tasks) + - all — всё вместе + +Запуск: await run_sync("all") +""" +from __future__ import annotations + +import logging +import urllib.parse + +import httpx + +import config +from database.connection import get_db +from database.queries.intradesk import ( + finish_sync_log, + start_sync_log, + sync_whitelist_from_intradesk, + upsert_client, + upsert_task, + upsert_user, +) + +logger = logging.getLogger(__name__) + +_PAGE_SIZE = 100 + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _build_url(path: str, api_key: str, extra: dict | None = None) -> str: + """ + Собирает URL с параметрами. + Ключи начинающиеся на '$' вставляются без URL-кодирования имени + (OData требует именно $filter, $top и т.д.). + """ + base = f"{config.INTRADESK_BASE_URL.rstrip('/')}{path}?ApiKey={api_key}" + if extra: + for k, v in extra.items(): + base += f"&{k}={urllib.parse.quote(str(v), safe='')}" + return base + + +async def _fetch_page( + client: httpx.AsyncClient, path: str, api_key: str, extra: dict, skip: int +) -> tuple[list[dict], dict[int, str]]: + """Запросить одну страницу OData и вернуть (items, dicts_map).""" + params = dict(extra) + params["$top"] = str(_PAGE_SIZE) + params["$skip"] = str(skip) + url = _build_url(path, api_key, params) + resp = await client.get(url, timeout=60.0) + resp.raise_for_status() + data = resp.json() + + items: list[dict] = data.get("value", []) + + # Аккумулируем словари (статусы, приоритеты, пользователи, группы...) + dicts_map: dict[int, str] = {} + for d in data.get("dictionaries", []): + raw_name = d.get("name") + if isinstance(raw_name, dict): + # name может быть {"ru": "...", "en": null} + raw_name = raw_name.get("ru") or raw_name.get("en") or "" + dicts_map[d["id"]] = raw_name or "" + + return items, dicts_map + + +async def _fetch_all( + path: str, api_key: str, extra: dict | None = None +) -> tuple[list[dict], dict[int, str]]: + """Постранично скачать все записи OData-эндпоинта.""" + all_items: list[dict] = [] + all_dicts: dict[int, str] = {} + extra = extra or {} + + async with httpx.AsyncClient() as client: + skip = 0 + while True: + items, dicts_map = await _fetch_page(client, path, api_key, extra, skip) + all_items.extend(items) + all_dicts.update(dicts_map) + if len(items) < _PAGE_SIZE: + break + skip += _PAGE_SIZE + + return all_items, all_dicts + + +# ── Sync functions ──────────────────────────────────────────────────────────── + +async def sync_clients() -> int: + """Синхронизировать клиентов/организации.""" + items, _ = await _fetch_all( + "/settings/odata/v2/Clients", + config.INTRADESK_API_KEY, + {"$filter": "(isarchived eq false)", "$orderby": "name asc"}, + ) + db = get_db() + for item in items: + await upsert_client(db, item) + await db.commit() + logger.info("sync_clients: %d records", len(items)) + return len(items) + + +async def sync_users() -> int: + """Синхронизировать контактные лица клиентов.""" + items, _ = await _fetch_all( + "/settings/odata/ClientUsers", + config.INTRADESK_API_KEY, + {"$filter": "(isarchived eq false)", "$orderby": "id desc"}, + ) + db = get_db() + for item in items: + await upsert_user(db, item) + await db.commit() + logger.info("sync_users: %d records", len(items)) + return len(items) + + +async def sync_whitelist() -> int: + """Обновить organizations + org_users из уже синхронизированных данных Intradesk.""" + db = get_db() + count = await sync_whitelist_from_intradesk(db) + logger.info("sync_whitelist: %d org_users затронуто", count) + return count + + +async def sync_tasks() -> int: + """Синхронизировать заявки по нашей группе исполнителей.""" + if not config.INTRADESK_TASKS_API_KEY: + logger.warning("sync_tasks: INTRADESK_TASKS_API_KEY не задан, пропуск") + return 0 + + # Строим OData $filter + conditions: list[str] = [] + if config.INTRADESK_EXECUTOR_GROUP: + conditions.append(f"executorgroup eq {config.INTRADESK_EXECUTOR_GROUP}") + for eid in config.INTRADESK_EXECUTORS: + conditions.append(f"executor eq {eid}") + + if not conditions: + logger.warning("sync_tasks: не задан ни executor_group, ни executors — фильтр пуст") + filter_str = "isarchived eq false" + else: + filter_str = "(" + " or ".join(conditions) + ")" + + items, dicts_map = await _fetch_all( + "/tasklist/odata/v3/tasks", + config.INTRADESK_TASKS_API_KEY, + {"$filter": filter_str, "$orderby": "updatedat desc"}, + ) + db = get_db() + for item in items: + await upsert_task(db, item, dicts_map) + await db.commit() + logger.info("sync_tasks: %d records", len(items)) + return len(items) + + +# ── Orchestrator ────────────────────────────────────────────────────────────── + +_SYNC_FUNCS = { + "clients": sync_clients, + "users": sync_users, + "tasks": sync_tasks, +} + +# После каких типов синхронизации нужно обновить белый список +_NEEDS_WHITELIST = {"clients", "users", "all"} + + +async def run_sync(sync_type: str) -> dict: + """ + Запустить синхронизацию и вернуть результат. + + sync_type: 'clients' | 'users' | 'tasks' | 'all' + Возвращает dict: {sync_type, status, records_synced, error_message} + """ + db = get_db() + log_id = await start_sync_log(db, sync_type) + + if sync_type == "all": + funcs = [sync_clients, sync_users, sync_tasks, sync_whitelist] + elif sync_type in _NEEDS_WHITELIST: + funcs = [_SYNC_FUNCS[sync_type], sync_whitelist] + else: + funcs = [_SYNC_FUNCS[sync_type]] + + total = 0 + error_msg = "" + try: + for fn in funcs: + total += await fn() + status = "success" + except Exception as exc: + error_msg = str(exc) + status = "error" + logger.exception("Ошибка синхронизации '%s': %s", sync_type, exc) + + await finish_sync_log(db, log_id, status, total, error_msg) + return { + "sync_type": sync_type, + "status": status, + "records_synced": total, + "error_message": error_msg, + } diff --git a/requirements.txt b/requirements.txt index 61f7237..65af254 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ jinja2==3.1.6 aiosqlite python-dotenv python-multipart +httpx>=0.27.0 diff --git a/run.bat b/run.bat index 7b87cef..f8e0d9e 100644 --- a/run.bat +++ b/run.bat @@ -15,6 +15,9 @@ if exist "venv\Scripts\activate.bat" ( :: Устанавливаем зависимости если нужно (раскомментировать при первом запуске) :: pip install -r requirements.txt +echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/ +echo. + python main.py if %ERRORLEVEL% neq 0 (