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 }})
+
+ | Имя | Email | Телефон |
+
+ {% for u in users %}
+
+ | {{ u.name or (u.last_name ~ ' ' ~ u.first_name ~ ' ' ~ u.middle_name)|trim }} |
+ {{ u.email or '—' }} |
+ {{ u.phone or '—' }} |
+
+ {% endfor %}
+
+
+
+{% endif %}
+
+{# ── Фильтр заявок ── #}
+
+
+
+
+ Всего заявок: {{ total }}
+
+
+
+ {# ── Таблица заявок ── #}
+ {% if tasks %}
+
+
+
+ | # |
+ Тема |
+ Статус |
+ Приоритет |
+ Сервис |
+ Исполнитель |
+ Создана |
+ Обновлена |
+
+
+
+ {% for t in tasks %}
+
+ | {{ 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 '—' }}
+ |
+
+ {% if t.description %}
+
+ |
+
+ {{ t.description | striptags | truncate(200) }}
+ |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+ {# ── Пагинация ── #}
+ {% 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 clients %}
+
+
+
+ | ID |
+ Название |
+ Тип |
+ Email |
+ Адрес |
+ Обновлён |
+ |
+
+
+
+ {% for c in clients %}
+
+ | {{ 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 '—' }}
+ |
+
+
+ 📋 Заявки
+
+ |
+
+ {% endfor %}
+
+
+
+ Всего: {{ 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' %}
+
+ | {{ label }} |
+ {% set rec = last[key] %}
+ {% if rec %}
+ {{ rec.finished_at or rec.started_at }} |
+ {{ rec.records_synced }} |
+ {% else %}
+ Не выполнялась |
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+{# ── История запусков ── #}
+
+
История запусков (последние 20)
+ {% if logs %}
+
+
+
+ | # | Тип | Начало | Конец |
+ Статус | Записей | Ошибка |
+
+
+
+ {% for log in logs %}
+
+ | {{ 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 '' }}
+ |
+
+ {% endfor %}
+
+
+ {% 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 (