diff --git a/README.md b/README.md index dbb8e5c..524b369 100644 --- a/README.md +++ b/README.md @@ -2,88 +2,96 @@ Canonical implementation workspace for `hub.edevs.tech`. -## Local start +## Быстрый старт (одна минута) + +Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose +на Linux). Никаких параметров заранее задавать не нужно — всё спросит браузер. + +Windows (PowerShell): ```powershell -Copy-Item .env.example .env +git clone chatbolls +cd chatbolls/code/custocrm .\scripts\start.ps1 ``` -По умолчанию локально запускается облачный режим. Коробочный режим запускается -тем же штатным контуром, но с явным признаком поставки: +Linux / macOS: + +```bash +git clone chatbolls +cd chatbolls/code/custocrm +./scripts/start.sh +``` + +Скрипт копирует `.env.example` в `.env` (если его нет), собирает образы и +поднимает стек. Когда в логах появится готовность, откройте +**http://localhost** — вместо входа система покажет **мастер первого запуска**: +название организации, ваше имя, e-mail и пароль владельца, переключатель +«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под +владельцем. Мастер доступен только пока в системе нет ни одной организации; +после создания владельца он закрывается навсегда. + +### Демо-данные + +Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы, +агенты с подключениями Telegram/MAX/почта/веб-виджет, база знаний с +вложениями, диалоги во всех состояниях (AI ведёт, ждёт оператора, ведёт +сотрудник, закрыт, спам, архив), метки, приоритеты, заметки, шаблоны +ответов, голосовые, портал поддержки со статьями, звонки, уведомления и +история использования AI за 30 дней. Набор покрывает каждую модель системы — +это проверяет тест `identity.test_seed_demo`. + +Демо ставится в вашу организацию и удаляется целиком одной кнопкой: **Настройки → +Демо-данные**. Там же — учётные записи демо-сотрудников из разных групп, чтобы +посмотреть систему их глазами (пароль общий и намеренно публичный — +`Chatbolls-Demo-2026`). Ваши данные при удалении не затрагиваются: сид ведёт +реестр созданных записей и удаляет ровно их. + +Для разработки то же доступно из командной строки: + +```bash +docker compose run --rm backend-app python manage.py seed_demo --organization --apply +docker compose run --rm backend-app python manage.py seed_demo --organization --remove +``` + +Редактируемые данные — `apps/backend/hub_platform/identity/demo_seed/data/` +(JSON-манифест на домен, `media/` — вложения, аватары, голосовые). Голосовые +сообщения читаются из `media/voice/` (см. README там); если файла нет, сообщение +пропускается. + +### Режим поставки + +По умолчанию локально запускается облачный режим. Коробочный режим — тем же +контуром с явным признаком поставки: ```powershell .\scripts\start.ps1 -Mode Cloud .\scripts\start.ps1 -Mode SelfHosted ``` -Приложение не определяет режим по домену, числу организаций или данным тарифа. +Приложение не определяет режим по домену, числу организаций или данным. Единственный источник — `CUS_DELIVERY_MODE` со значением `CLOUD` или `SELF_HOSTED`. В production переменная обязательна; шаблон коробочного экземпляра `env.example` уже содержит `SELF_HOSTED`. -The local compose stack contains: +### Что поднимается -- isolated app, platform, and loopback-only admin Django runtimes; -- background worker; -- PostgreSQL; -- Redis; -- Internal Hub UI; -- Web Chat UI; -- local Nginx reverse proxy. +- изолированные Django-рантаймы app, platform и loopback-only admin; +- фоновый worker (outbox, поллинг мессенджеров, установка демо); +- PostgreSQL и Redis; +- Internal Hub UI и Web Chat UI; +- локальный Nginx reverse proxy. -No production secrets are stored in the repository. +Секретов production в репозитории нет. -Default local URLs: +Локальные адреса: -- App gateway: `http://app.localhost/` -- Platform health: `http://platform.localhost/api/v1/health/live/` -- Django admin (loopback only): `http://127.0.0.1:18001/admin/` -- Internal Hub UI (direct Vite): `http://localhost:5173` +- Приложение: `http://localhost` (то же — `http://app.localhost/`) +- Health платформы: `http://platform.localhost/api/v1/health/live/` +- Django admin (только loopback): `http://127.0.0.1:18001/admin/` +- Internal Hub UI напрямую (Vite): `http://localhost:5173` - Web Chat: `http://localhost:5175` -- App API (direct): `http://localhost:8010/api/v1` - -Local accounts (TOTP disabled). These credentials are fixed — do not change them: - -- OWNER — `owner@edevs.tech` / `Owner-Local-2026` -- OPERATOR — `a.kotova@edevs.tech` / `Operator-Local-2026` - -Bootstrap the organization and both accounts: - -```powershell -docker compose run --rm backend-app python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров" -``` - -## Optional demo seed - -The demo seed is a Django management command (`seed_demo`) that loads a full -fictional dataset for a Russian IIoT vendor company «Северная Верфь» (products -«Вектор» and «Репер»): organization with logo, staff with access profiles, -product catalog with offers and prices, channels and AI agents, knowledge base -with file attachments, conversations, orders, external sales, support contracts -and a public help-center portal, calls, and notifications. It is idempotent and -never runs during `start.ps1`, `docker compose up`, or migrations. - -The editable data lives in -`apps/backend/hub_platform/identity/demo_seed/data/` (one JSON manifest per -domain, plus `media/` for attachments). The command is baked into the backend -image, so the same dataset works for local testing and cloud installation. - -Run it only after the local dev stack is running: - -```powershell -.\scripts\seed-demo.ps1 -``` - -Or directly via management command (dry-run by default, `--apply` to write): - -``` -python manage.py seed_demo --apply -``` - -The `demo-seed` one-shot service is defined only by `compose.dev.yaml`. Outside -`DEBUG` (e.g. cloud installation) the command requires `--force`. Production -compose has no seed service. +- App API напрямую: `http://localhost:8010/api/v1` ## Tests diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py index e432824..f6067c7 100644 --- a/apps/backend/hub_backend/settings_base.py +++ b/apps/backend/hub_backend/settings_base.py @@ -121,6 +121,8 @@ CHANNEL_LAYERS = { } } +DATABASE_ROUTERS = ["hub_platform.tenancy.routing.ForcedAliasRouter"] + AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, { diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 3d3459d..862fd43 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -1,6 +1,7 @@ from django.urls import include, path from rest_framework.schemas import get_schema_view +from hub_platform.identity.demo_views import DemoMediaView from hub_platform.webchat.views import WidgetLoaderView urlpatterns = [ @@ -11,6 +12,12 @@ urlpatterns = [ name="openapi-schema", ), path("api/v1/auth/", include("hub_platform.identity.auth_urls")), + path("api/v1/setup/", include("hub_platform.identity.setup_urls")), + path( + "api/v1/demo-media/avatars/", + DemoMediaView.as_view(), + name="demo-media", + ), path( "api/v1/organizations//company/", include("hub_platform.identity.company_urls"), diff --git a/apps/backend/hub_platform/identity/company_urls.py b/apps/backend/hub_platform/identity/company_urls.py index 8b5fd86..4c50df4 100644 --- a/apps/backend/hub_platform/identity/company_urls.py +++ b/apps/backend/hub_platform/identity/company_urls.py @@ -1,6 +1,6 @@ from django.urls import path -from hub_platform.identity import administration_views, group_views +from hub_platform.identity import administration_views, demo_views, group_views urlpatterns = [ path("groups/", group_views.GroupListView.as_view(), name="group-list"), @@ -20,6 +20,7 @@ urlpatterns = [ administration_views.LaunchChecklistView.as_view(), name="launch-checklist", ), + path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"), path( "administration/audit/", administration_views.AuditListView.as_view(), diff --git a/apps/backend/hub_platform/identity/demo_models.py b/apps/backend/hub_platform/identity/demo_models.py new file mode 100644 index 0000000..3aec30a --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_models.py @@ -0,0 +1,67 @@ +"""Реестр демо-данных организации. + +Демо ставится в организацию установщика (мультиорг-переключателя нет — +решение владельца). Чтобы удаление было точным и не задело реальные данные, +каждая созданная сидом запись регистрируется в ``DemoRecord``; удаление идёт +по реестру в обратном порядке создания. +""" + +from __future__ import annotations + +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class DemoDatasetStatus(models.TextChoices): + INSTALLING = "INSTALLING", "Устанавливается" + INSTALLED = "INSTALLED", "Установлены" + REMOVING = "REMOVING", "Удаляются" + FAILED = "FAILED", "Ошибка" + + +class DemoDataset(models.Model): + """Состояние демо-набора организации: одна запись на организацию.""" + + organization = models.OneToOneField( + "identity.Organization", on_delete=models.CASCADE, related_name="demo_dataset" + ) + status = models.CharField( + max_length=16, choices=DemoDatasetStatus.choices, default=DemoDatasetStatus.INSTALLING + ) + requested_by = models.ForeignKey( + "identity.HumanUser", null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + records_count = models.PositiveIntegerField(default=0) + error = models.TextField(blank=True, default="") + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "identity_demodataset" + + def __str__(self) -> str: + return f"demo:{self.organization_id}:{self.status}" + + +class DemoRecord(models.Model): + """Одна созданная сидом запись: модель + первичный ключ + порядок создания.""" + + organization = models.ForeignKey( + "identity.Organization", on_delete=models.CASCADE, related_name="+" + ) + dataset = models.ForeignKey(DemoDataset, on_delete=models.CASCADE, related_name="records") + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="+") + object_id = models.CharField(max_length=64) + sequence = models.PositiveBigIntegerField() + + class Meta: + db_table = "identity_demorecord" + indexes = [models.Index(fields=["dataset", "sequence"])] + constraints = [ + models.UniqueConstraint( + fields=["dataset", "content_type", "object_id"], name="identity_demorecord_unique" + ) + ] + + def __str__(self) -> str: + return f"{self.content_type_id}:{self.object_id}" diff --git a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json index 3e77b35..a4ee2ff 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/catalog.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/catalog.json @@ -1,15 +1,7 @@ { "schemaVersion": 1, + "_comment": "Product — скрытая техническая ось (ADR-HUB-0041): нужен порталу поддержки и веб-виджету.", "products": [ - { - "code": "vektor", - "name": "Вектор", - "siteUrl": "https://vektor.severnayaverf.ru" - }, - { - "code": "reper", - "name": "Репер", - "siteUrl": "https://reper.severnayaverf.ru" - } + {"code": "atelie-nord-site", "name": "Сайт ателье", "siteUrl": "https://atelie-nord.ru"} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json index 99aad17..524325b 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/channels_ai.json @@ -1,305 +1,340 @@ { "schemaVersion": 1, - "integrations": [ - { - "key": "llm-provider", - "provider": "OPENROUTER", - "name": "OpenRouter (CustoAI)", - "kind": "LLM_PROVIDER", - "channel": null, - "status": "OK" + "_comment": "Агенты = карточки поверх Channel+AIAgent (ADR-HUB-0041 §4). Подключения — точки входа диалогов; LLM-провайдер общий на организацию (BYOK). Веб-виджетов два: анонимный на сайте (агент «Консультант») и авторизованный в личном кабинете (агент «Поддержка сайта», политика обязательной идентичности — SPEC-HUB-0027 P4/P5).", + "llmProvider": { + "key": "llm", + "provider": "OPENROUTER", + "name": "OpenRouter (демо-ключ)", + "secret": "sk-or-demo-not-a-real-key", + "config": { + "baseUrl": "https://openrouter.ai/api/v1", + "defaultModel": "openai/gpt-4o-mini" }, - { - "key": "site-max", - "provider": "MAX", - "name": "Сайт — MAX", - "channel": "site-main", - "status": "OK" - }, - { - "key": "site-tg", - "provider": "TELEGRAM", - "name": "Сайт — Telegram", - "channel": "site-main", - "status": "OK" - }, - { - "key": "site-web", - "provider": "WEB", - "name": "Сайт — веб-чат", - "channel": "site-main", - "status": "OK" - }, - { - "key": "vektor-sales-tg", - "provider": "TELEGRAM", - "name": "Вектор продажи — Telegram", - "channel": "vektor-sales", - "status": "OK" - }, - { - "key": "vektor-sales-web", - "provider": "WEB", - "name": "Вектор продажи — веб-чат", - "channel": "vektor-sales", - "status": "OK" - }, - { - "key": "reper-sales-tg", - "provider": "TELEGRAM", - "name": "Репер продажи — Telegram", - "channel": "reper-sales", - "status": "OK" - }, - { - "key": "reper-sales-max", - "provider": "MAX", - "name": "Репер продажи — MAX", - "channel": "reper-sales", - "status": "UNCHECKED" - }, - { - "key": "vektor-support-web", - "provider": "WEB", - "name": "Вектор поддержка — веб-чат", - "channel": "vektor-support", - "status": "OK" - }, - { - "key": "reper-support-web", - "provider": "WEB", - "name": "Репер поддержка — веб-чат", - "channel": "reper-support", - "status": "OK" - }, - { - "key": "partners-tg", - "provider": "TELEGRAM", - "name": "Партнёрская линия — Telegram", - "channel": "partners", - "status": "OK" - }, - { - "key": "support-email", - "provider": "EMAIL", - "name": "Почта поддержки", - "channel": "site-main", - "status": "OK", - "config": { - "imap_host": "imap.yandex.ru", - "imap_port": 993, - "imap_ssl": true, - "smtp_host": "smtp.yandex.ru", - "smtp_port": 465, - "smtp_ssl": true, - "email": "support@severnayaverf.ru" - } - } - ], - "channels": [ - { - "code": "site-main", - "name": "Северная Верфь — главный сайт", - "product": null, - "providerIntegration": "llm-provider", - "policy": { - "requires_authenticated_product_identity": false, - "allow_anonymous_sessions": true, - "allow_self_reported_contact": true, - "allow_sales_attribution": false, - "allow_checkout_actions": false - }, - "group": "sales" - }, - { - "code": "vektor-sales", - "name": "Вектор — продажи", - "product": "vektor", - "providerIntegration": "llm-provider", - "policy": { - "requires_authenticated_product_identity": false, - "allow_anonymous_sessions": true, - "allow_self_reported_contact": true, - "allow_sales_attribution": true, - "allow_checkout_actions": true - }, - "group": "sales" - }, - { - "code": "reper-sales", - "name": "Репер — продажи", - "product": "reper", - "providerIntegration": "llm-provider", - "policy": { - "requires_authenticated_product_identity": false, - "allow_anonymous_sessions": true, - "allow_self_reported_contact": true, - "allow_sales_attribution": true, - "allow_checkout_actions": true - }, - "group": "sales" - }, - { - "code": "vektor-support", - "name": "Вектор — поддержка", - "product": "vektor", - "policy": { - "requires_authenticated_product_identity": true, - "allow_anonymous_sessions": false, - "allow_self_reported_contact": false, - "allow_sales_attribution": false, - "allow_checkout_actions": false - }, - "group": "support" - }, - { - "code": "reper-support", - "name": "Репер — поддержка", - "product": "reper", - "policy": { - "requires_authenticated_product_identity": true, - "allow_anonymous_sessions": false, - "allow_self_reported_contact": false, - "allow_sales_attribution": false, - "allow_checkout_actions": false - }, - "group": "support" - }, - { - "code": "partners", - "name": "Партнёрская линия", - "product": null, - "isActive": false, - "policy": { - "requires_authenticated_product_identity": false, - "allow_anonymous_sessions": true, - "allow_self_reported_contact": true, - "allow_sales_attribution": false, - "allow_checkout_actions": false - }, - "group": null - }, - { - "code": "reper-help-widget", - "name": "Репер — виджет Help-центра", - "product": "reper", - "policy": { - "requires_authenticated_product_identity": false, - "allow_anonymous_sessions": true, - "allow_self_reported_contact": true, - "allow_sales_attribution": false, - "allow_checkout_actions": false - }, - "group": "support" - } - ], + "status": "UNCHECKED" + }, "agents": [ { - "channel": "site-main", - "name": "Главный ассистент сайта", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Виртуальный ассистент компании «Северная Верфь» — производителя промышленной телеметрии. Помогает посетителям сайта сориентироваться в продуктах Вектор и Репер.", - "tone": "Дружелюбный, деловой, без излишней фамильярности. Обращается на «вы».", - "instructions": "Если вопрос касается конкретного продукта или условий покупки — переключай диалог на менеджера продаж. Не обещай скидки и сроки, которых нет в базе знаний.", + "key": "consultant", + "code": "consultant", + "name": "Консультант", + "group": "consultants", + "aiStatus": "ACTIVE", + "persona": "Ты — консультант ателье «Норд». Ателье шьёт шторы, тюль и текстиль для дома по индивидуальным размерам, доставляет по России.", + "tone": "Дружелюбно, коротко, без канцелярита. Обращайся по имени, если клиент представился.", + "instructions": "Отвечай только по знаниям ателье. Сроки, цены и статус заказа называй только из базы знаний; если ответа нет — скажи, что уточнишь у коллег, и передай диалог сотруднику. Не обещай скидок. Если клиент просит человека — сразу передавай диалог.", "knowledge": [ - "vektor-overview", - "reper-overview" + "pricing", + "fabrics", + "measure", + "delivery", + "care" + ], + "portalArticles": [ + "kak-snyat-merki", + "sroki-dostavki" + ], + "limits": { + "dailyCostUsd": 300 + }, + "createdDaysAgo": 41 + }, + { + "key": "support", + "code": "site-support", + "name": "Поддержка сайта", + "group": "support", + "product": "atelie-nord-site", + "aiStatus": "ACTIVE", + "persona": "Ты — помощник службы поддержки сайта ателье «Норд»: личный кабинет, заказы, оплата, доставка, возвраты.", + "tone": "Спокойно и по делу.", + "instructions": "Сначала проверь знания о личном кабинете и возвратах. Вопросы по конкретному заказу без номера заказа — попроси номер. Жалобы на качество — передай сотруднику с пометкой.", + "knowledge": [ + "delivery", + "returns", + "account" + ], + "portalArticles": [ + "vozvrat-i-obmen", + "lichnyj-kabinet" + ], + "limits": {}, + "createdDaysAgo": 30, + "policy": { + "requires_authenticated_product_identity": true, + "allow_anonymous_sessions": false, + "allow_self_reported_contact": false + } + }, + { + "key": "reception", + "code": "reception", + "name": "Приёмная", + "group": null, + "aiStatus": "DRAFT", + "persona": "", + "tone": "", + "instructions": "", + "knowledge": [], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 3 + }, + { + "key": "legacy", + "code": "old-line", + "name": "Старая линия (Instagram)", + "group": "operators", + "isActive": false, + "aiStatus": "DISABLED", + "persona": "Консультант ателье.", + "tone": "", + "instructions": "Канал закрыт весной 2026.", + "knowledge": [ + "pricing" + ], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 180 + } + ], + "connections": [ + { + "key": "tg", + "provider": "TELEGRAM", + "name": "Telegram · @AtelieNordBot", + "agent": "consultant", + "status": "OK", + "secret": "0000000000:DEMO-token-not-real", + "config": { + "username": "AtelieNordBot" + }, + "createdDaysAgo": 40 + }, + { + "key": "max", + "provider": "MAX", + "name": "MAX · Ателье Норд", + "agent": "consultant", + "status": "OK", + "secret": "demo-max-token", + "config": {}, + "createdDaysAgo": 25 + }, + { + "key": "email", + "provider": "EMAIL", + "name": "Почта · info@atelie-nord.ru", + "agent": "support", + "status": "OK", + "secret": "demo-imap-password", + "config": { + "email": "info@atelie-nord.ru", + "imapHost": "imap.atelie-nord.ru", + "smtpHost": "smtp.atelie-nord.ru" + }, + "createdDaysAgo": 29 + }, + { + "key": "web", + "provider": "WEB", + "name": "Виджет сайта atelie-nord.ru", + "agent": "consultant", + "status": "OK", + "config": { + "title": "Ателье Норд", + "greeting": "Здравствуйте! Подскажем по тканям, срокам и заказам.", + "quick_replies": [ + "Сроки пошива", + "Статус заказа", + "Возврат" + ], + "allowed_origins": [ + "https://atelie-nord.ru" + ] + }, + "createdDaysAgo": 28 + }, + { + "key": "web_account", + "provider": "WEB", + "name": "Виджет личного кабинета", + "agent": "support", + "status": "OK", + "config": { + "title": "Поддержка · Ателье Норд", + "greeting": "Здравствуйте! Мы видим ваш заказ — чем помочь?", + "quick_replies": [ + "Статус заказа", + "Изменить адрес", + "Возврат" + ], + "allowed_origins": [ + "https://atelie-nord.ru", + "https://lk.atelie-nord.ru" + ] + }, + "createdDaysAgo": 27 + }, + { + "key": "tg_delivery", + "provider": "TELEGRAM", + "name": "Telegram · бот доставки", + "agent": "reception", + "status": "ERROR", + "lastError": "401 Unauthorized: bot token was revoked in @BotFather", + "secret": "1111111111:DEMO-revoked", + "config": { + "username": "AtelieNordDeliveryBot" + }, + "createdDaysAgo": 3 + } + ], + "knowledgeCategories": [ + { + "key": "sewing", + "name": "Пошив и ткани", + "children": [ + { + "key": "measure", + "name": "Замер и расчёт" + }, + { + "key": "fabrics", + "name": "Ткани" + } ] }, { - "channel": "vektor-sales", - "name": "Менеджер продаж Вектора", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Отвечает за продажи коробочной лицензии Вектор производственным предприятиям.", - "tone": "Деловой, экспертный, по сути.", - "instructions": "Уточняй масштаб производства и число участков — от этого зависит конфигурация. Предлагай продление поддержки как сопутствующую услугу.", - "knowledge": [ - "vektor-overview", - "vektor-deploy" + "key": "orders", + "name": "Заказ и доставка", + "children": [ + { + "key": "delivery", + "name": "Доставка" + }, + { + "key": "returns", + "name": "Возврат и обмен" + } ] }, { - "channel": "reper-sales", - "name": "Менеджер продаж Репера", - "model": "anthropic/claude-sonnet-4.6", - "status": "ACTIVE", - "persona": "Отвечает за продажи облачной подписки на телематику Репер.", - "tone": "Деловой, ориентируется на выгоду клиента.", - "instructions": "Уточняй размер парка техники и типы транспорта. Для парков от 50 единиц предлагай годовую оплату — она выгоднее помесячной.", - "knowledge": [ - "reper-overview", - "reper-telematics" - ] + "key": "site", + "name": "Сайт и личный кабинет" } ], "knowledge": [ { - "key": "vektor-overview", - "title": "Вектор — обзор продукта", - "description": "Краткое описание SCADA-системы Вектор для предпродажных консультаций.", - "content": "Вектор — сервер SCADA-мониторинга промышленного оборудования. Разворачивается на площадке заказчика, поставляется коробочной лицензией на один производственный участок. Поддерживает опрос контроллеров по OPC UA и Modbus, сбор метрик в реальном времени, алёртинг при выходе параметров за допуск.", - "visibility": "ORGANIZATION" - }, - { - "key": "vektor-deploy", - "title": "Вектор — руководство по развёртыванию", - "description": "Требования к серверу и шаги развёртывания коробочной лицензии. Приложен файл с детальным руководством.", - "content": "Подробное руководство по развёртыванию коробочной лицензии Вектор приведено во вложении. Требуется сервер с ОС Linux, 8 ГБ ОЗУ, доступ к контроллерам по сети.", - "visibility": "DEPARTMENTS", + "key": "pricing", + "category": "measure", + "title": "Расчёт стоимости пошива штор", + "description": "Формула расчёта, коэффициенты сборки, минимальный заказ", + "content": "Стоимость = ткань (метраж × цена за метр) + пошив (по ширине изделия) + фурнитура.\n\nКоэффициент сборки: 1.5 для плотных портьер, 2.0–2.5 для тюля. Ширина ткани с учётом сборки = ширина карниза × коэффициент.\n\nПошив: 450 ₽ за погонный метр готовой ширины для портьер, 350 ₽ — для тюля. Подгиб по низу входит. Люверсы — 90 ₽ за штуку, шторная лента — 120 ₽ за метр.\n\nМинимальный заказ — 3 000 ₽. Расчёт консультант делает по ширине и высоте окна; замер выезд по Санкт-Петербургу бесплатный при заказе от 15 000 ₽.", "attachments": [ - "vektor-deploy-guide.md" - ] + "raschet-stoimosti.md" + ], + "createdDaysAgo": 40 }, { - "key": "reper-overview", - "title": "Репер — обзор продукта", - "description": "Краткое описание телематики транспорта для предпродажных консультаций.", - "content": "Репер — облачная платформа телематики для транспорта и спецтехники. Сбор данных с бортовых контроллеров, контроль топлива, моточасов, геозон. Оплата по подписке за единицу техники.", - "visibility": "ORGANIZATION" - }, - { - "key": "reper-telematics", - "title": "Репер — спецификация телематики", - "description": "Поддерживаемые протоколы и бортовые контроллеры. Приложен файл со спецификацией.", - "content": "Поддерживаемые протоколы обмена с бортовыми контроллерами и список совместимого оборудования приведены во вложении.", - "visibility": "DEPARTMENTS", + "key": "fabrics", + "category": "fabrics", + "title": "Каталог тканей 2026", + "description": "Портьерные и тюлевые ткани в наличии, цены за метр", + "content": "Портьерные: блэкаут «Норд» (100% затемнение, 1 490 ₽/м, 14 цветов), лён смесовый (890 ₽/м, 9 цветов), бархат «Верона» (2 190 ₽/м, 6 цветов), канвас (690 ₽/м, 12 цветов).\n\nТюль: вуаль (390 ₽/м), сетка «Грек» (520 ₽/м), лён-тюль (740 ₽/м).\n\nВсе ткани ширина 280–300 см. Образцы отправляем почтой бесплатно — до 5 штук.", "attachments": [ - "reper-telematics-spec.md" - ] + { + "file": "katalog-tkanej-2026.pdf", + "text": "katalog-tkanej-2026.txt", + "contentType": "application/pdf" + } + ], + "createdDaysAgo": 38 + }, + { + "key": "measure", + "category": "measure", + "title": "Как снять мерки самостоятельно", + "description": "Инструкция для клиента: карниз, высота, припуски", + "content": "1. Измерьте длину карниза от края до края (не окна!).\n2. Высоту измеряйте от крючка карниза (или верха трубы) до желаемой длины: до подоконника (минус 1 см), до пола (минус 1–2 см) или «в пол» с напуском (+3–5 см).\n3. Для штор на люверсах прибавьте 3 см сверху.\n4. Сфотографируйте окно целиком — консультант проверит расчёт.", + "attachments": [], + "createdDaysAgo": 36 + }, + { + "key": "delivery", + "category": "delivery", + "title": "Сроки пошива и доставка", + "description": "Пошив 5–7 рабочих дней, доставка СДЭК и курьером", + "content": "Пошив занимает 5–7 рабочих дней после согласования расчёта и оплаты. Срочный пошив (3 рабочих дня) +30% к стоимости пошива, только при наличии ткани.\n\nДоставка: по Санкт-Петербургу курьер 400 ₽ (бесплатно от 10 000 ₽), интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00; курьер звонит за час. По России — СДЭК до пункта выдачи или до двери, 3–5 рабочих дней с момента отправки, трек-номер приходит на почту в день отправки. Казань, Москва, Нижний Новгород — обычно 3 дня.\n\nПеренос интервала доставки — бесплатно, не позднее 18:00 накануне.", + "attachments": [ + "dostavka-i-oplata.txt" + ], + "createdDaysAgo": 35 + }, + { + "key": "returns", + "category": "returns", + "title": "Возврат и обмен", + "description": "14 дней, условия для изделий по индивидуальным размерам", + "content": "Готовые изделия (подушки, покрывала из каталога) — возврат 14 дней без объяснения причин, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия согласованному заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен на другой цвет ткани в наличии, срок 5–7 рабочих дней; деньги возвращаем по желанию клиента в течение 10 дней.\n\nДля заявки нужны номер заказа и фото изделия. Заявку оформляет сотрудник, не AI.", + "attachments": [], + "createdDaysAgo": 33 + }, + { + "key": "care", + "category": "fabrics", + "title": "Уход за шторами", + "description": "Стирка, глажка, чистка по типам тканей", + "content": "Блэкаут: деликатная стирка 30°, без отжима, гладить с изнанки на минимальной температуре. Лён: стирка 40°, усадка до 3% заложена в расчёт. Бархат: только сухая чистка. Тюль: стирка 30° в мешке, сушить на карнизе.", + "attachments": [], + "createdDaysAgo": 20 + }, + { + "key": "account", + "category": "site", + "title": "Личный кабинет на сайте", + "description": "Регистрация, восстановление пароля, статусы заказов", + "content": "Вход по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут; если письма нет — проверьте «Спам» и правильность адреса. Повторный запрос возможен через 10 минут.\n\nСтатусы заказа в кабинете: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен». Изменить адрес доставки можно до статуса «Передан в доставку» — через сотрудника поддержки.", + "attachments": [], + "createdDaysAgo": 28 + }, + { + "key": "old_prices", + "category": "measure", + "title": "Прайс 2024 (устарел)", + "description": "Старые цены — отключено, оставлено для истории", + "content": "Пошив 380 ₽/м, тюль 290 ₽/м. Не использовать.", + "attachments": [], + "isEnabled": false, + "createdDaysAgo": 400 } ], - "llmInvocations": [ - { - "channel": "vektor-sales", - "product": "vektor", - "purpose": "demo-chat-sales", - "model": "anthropic/claude-sonnet-4.6", - "promptTokens": 5200, - "completionTokens": 1380, - "costMicros": 312000, - "status": "SUCCESS" + "usage": { + "_comment": "История вызовов LLM за 30 дней для раздела «Использование AI»: генерируется по шаблону.", + "days": 30, + "chatPerDay": { + "consultant": [ + 6, + 14 + ], + "support": [ + 3, + 9 + ] }, - { - "channel": "reper-sales", - "product": "reper", - "purpose": "demo-chat-sales", - "model": "anthropic/claude-sonnet-4.6", - "promptTokens": 4100, - "completionTokens": 980, - "costMicros": 198000, - "status": "SUCCESS" - }, - { - "channel": "vektor-sales", - "product": "vektor", - "purpose": "demo-chat-sales", - "model": "anthropic/claude-sonnet-4.6", - "promptTokens": 3300, - "completionTokens": 420, - "costMicros": 88000, - "status": "BLOCKED" - } - ] + "embeddingPerDay": [ + 0, + 2 + ], + "model": "openai/gpt-4o-mini", + "promptTokens": [ + 600, + 1800 + ], + "completionTokens": [ + 80, + 260 + ], + "costMicrosPerToken": 0.6, + "failuresTotal": 3 + } } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/conversations.json b/apps/backend/hub_platform/identity/demo_seed/data/conversations.json index 14b7ecc..1f0b38e 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/conversations.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/conversations.json @@ -1,208 +1,840 @@ { "schemaVersion": 1, + "_comment": "Диалоги «Ателье Норд» по кадрам дизайн-базлайна v2. Время — относительное (minutesAgo/daysAgo) от момента установки. Аватары контактов — media/avatars, отдаются публичным роутом демо-медиа. Голосовые: file — из media/voice (см. README сида), если файла нет — сообщение пропускается с предупреждением.", "contacts": [ - { "key": "n.kuznetsova", "name": "Наталья Кузнецова", "phone": "+7 812 445 90 12" }, - { "key": "s.orlov", "name": "Сергей Орлов", "phone": "+7 343 261 04 77", "avatarUrl": "https://i.pravatar.cc/200?img=12" }, - { "key": "v.lebedeva", "name": "Виктория Лебедева", "phone": "+7 846 230 58 90", "avatarUrl": "https://i.pravatar.cc/200?img=5" }, - { "key": "p.gusev", "name": "Павел Гусев", "phone": "" }, - { "key": "o.frolova", "name": "Ольга Фролова", "phone": "+7 495 120 33 18" }, - { "key": "a.makarov", "name": "Антон Макаров", "phone": "+7 861 200 41 55" }, - { "key": "e.zaitseva", "name": "Екатерина Зайцева", "phone": "+7 831 410 76 22" }, - { "key": "r.borisov", "name": "Роман Борисов", "phone": "+7 846 300 88 04" }, - { "key": "f.grigorev", "name": "Фёдор Григорьев", "phone": "+7 495 778 12 09" } + { + "key": "maria", + "name": "Мария Соколова", + "phone": "+7 917 254-18-40", + "avatar": "maria-sokolova.jpg", + "createdDaysAgo": 34 + }, + { + "key": "irina", + "name": "Ирина Левина", + "phone": "+7 921 118-77-03", + "avatar": "irina-levina.jpg", + "createdDaysAgo": 60 + }, + { + "key": "olga", + "name": "Ольга Мельник", + "phone": "", + "avatar": "olga-melnik.jpg", + "createdDaysAgo": 12 + }, + { + "key": "sergey", + "name": "Сергей Крылов", + "phone": "+7 903 445-90-12", + "avatar": "sergey-krylov.jpg", + "createdDaysAgo": 9 + }, + { + "key": "darya", + "name": "Дарья Волкова", + "phone": "", + "avatar": "darya-volkova.jpg", + "createdDaysAgo": 2 + }, + { + "key": "viktor", + "name": "Виктор Панов · ООО «Дом текстиля»", + "phone": "+7 812 640-22-10", + "createdDaysAgo": 90 + }, + { + "key": "polina", + "name": "Полина Ракитина", + "phone": "", + "createdDaysAgo": 1 + }, + { + "key": "spammer", + "name": "", + "phone": "", + "createdDaysAgo": 0 + } ], "connectionIdentities": [ - { "key": "n.kuznetsova-tg", "contact": "n.kuznetsova", "connection": "vektor-sales-tg", "externalUserId": "demo-nkuznetsova", "displayName": "Наталья Кузнецова", "username": "nkuznetsova_plant" }, - { "key": "s.orlov-tg", "contact": "s.orlov", "connection": "vektor-sales-tg", "externalUserId": "demo-sorlov", "displayName": "Сергей Орлов", "username": "sorlov_ural" }, - { "key": "v.lebedeva-max", "contact": "v.lebedeva", "connection": "reper-sales-max", "externalUserId": "demo-vlebedeva", "displayName": "Виктория Лебедева", "username": "vlebedeva" }, - { "key": "p.gusev-tg", "contact": "p.gusev", "connection": "reper-sales-tg", "externalUserId": "demo-pgusev", "displayName": "Павел Гусев", "username": "pgusev_logistics" }, - { "key": "o.frolova-web", "contact": "o.frolova", "connection": "site-web", "externalUserId": "demo-ofrolova", "displayName": "Ольга Фролова" }, - { "key": "a.makarov-tg", "contact": "a.makarov", "connection": "site-tg", "externalUserId": "demo-amakarov", "displayName": "Антон Макаров", "username": "amakarov_int" }, - { "key": "r.borisov-max", "contact": "r.borisov", "connection": "site-max", "externalUserId": "demo-rborisov", "displayName": "Роман Борисов", "username": "rborisov" }, - { "key": "e.zaitseva-tg", "contact": "e.zaitseva", "connection": "vektor-sales-tg", "externalUserId": "demo-ezaitseva", "displayName": "Екатерина Зайцева", "username": "ezaitseva_nnp" }, - { "key": "f.grigorev-email", "contact": "f.grigorev", "connection": "support-email", "externalUserId": "f.grigorev@technosoft.ru", "displayName": "Фёдор Григорьев", "username": "f.grigorev@technosoft.ru" } + { + "key": "maria_tg", + "contact": "maria", + "connection": "tg", + "externalUserId": "482913001", + "displayName": "Мария Соколова", + "username": "m_sokolova" + }, + { + "key": "irina_tg", + "contact": "irina", + "connection": "tg", + "externalUserId": "482913002", + "displayName": "Ирина Л.", + "username": "irina_levina" + }, + { + "key": "irina_max", + "contact": "irina", + "connection": "max", + "externalUserId": "max-77103", + "displayName": "Ирина Левина" + }, + { + "key": "olga_max", + "contact": "olga", + "connection": "max", + "externalUserId": "max-88214", + "displayName": "Ольга Мельник" + }, + { + "key": "sergey_web", + "contact": "sergey", + "connection": "web_account", + "externalUserId": "web-sess-4402a", + "displayName": "Сергей Крылов" + }, + { + "key": "darya_tg", + "contact": "darya", + "connection": "tg", + "externalUserId": "482913005", + "displayName": "Даша", + "username": "dvolkova" + }, + { + "key": "viktor_tg", + "contact": "viktor", + "connection": "tg", + "externalUserId": "482913006", + "displayName": "Виктор Панов", + "username": "v_panov" + }, + { + "key": "polina_email", + "contact": "polina", + "connection": "email", + "externalUserId": "polina.rakitina@mail.ru", + "displayName": "Полина Ракитина" + }, + { + "key": "spam_tg", + "contact": "spammer", + "connection": "tg", + "externalUserId": "900000001", + "displayName": "Кредит Онлайн", + "username": "credit_fast_24" + } + ], + "labels": [ + { + "key": "urgent", + "name": "Срочно", + "color": "#d6336c" + }, + { + "key": "vip", + "name": "VIP", + "color": "#b8860b" + }, + { + "key": "return", + "name": "Возврат", + "color": "#7c3aed" + }, + { + "key": "delivery", + "name": "Доставка", + "color": "#1677ff" + }, + { + "key": "wholesale", + "name": "Оптовый", + "color": "#0f9d58" + } + ], + "replyTemplates": [ + { + "title": "Приветствие", + "text": "Здравствуйте! Меня зовут {имя}, я консультант ателье «Норд». Подскажите, пожалуйста, ширину карниза и высоту окна — сделаю расчёт." + }, + { + "title": "Сроки пошива", + "text": "Пошив занимает 5–7 рабочих дней после оплаты. Срочный вариант за 3 дня возможен при наличии ткани, +30% к стоимости пошива." + }, + { + "title": "Доставка СДЭК", + "text": "Отправляем СДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер придёт на почту в день отправки до 19:00." + }, + { + "title": "Перенос доставки", + "text": "Перенесли доставку на {дата}, интервал {интервал}. Курьер позвонит за час. Подтвердите, пожалуйста, что интервал подходит." + }, + { + "title": "Возврат — запрос фото", + "text": "Сожалею, что так вышло. Пришлите, пожалуйста, номер заказа и фото изделия при дневном свете — оформлю заявку на переделку или обмен." + }, + { + "title": "Образцы тканей", + "text": "Отправим до 5 образцов почтой бесплатно. Напишите, какие ткани и цвета интересуют, и адрес с индексом." + }, + { + "title": "Закрытие диалога", + "text": "Рада была помочь! Если появятся вопросы — пишите, отвечу в рабочее время с 10:00 до 20:00." + } ], "conversations": [ { - "key": "vektor-lead-active", - "channel": "vektor-sales", - "connection": "vektor-sales-tg", - "contact": "n.kuznetsova", - "externalChatId": "demo-vektor-lead-active", - "controlMode": "AI", - "expectedResponder": "AI", - "lastActivityMinutesAgo": 3, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Добрый день! Нас интересует мониторинг оборудования для литейного цеха. Что по Вектору?" }, - { "externalId": "m2", "author": "AI", "text": "Здравствуйте, Наталья! Вектор хорошо подходит для цехов с разнородными контроллерами — опрашивает их по OPC UA и Modbus. Подскажу конфигурацию под ваш масштаб." } - ] - }, - { - "key": "vektor-queue", - "channel": "vektor-sales", - "connection": "vektor-sales-tg", - "contact": "s.orlov", - "externalChatId": "demo-vektor-queue", - "controlMode": "PAUSED", - "expectedResponder": "OPERATOR", - "lastActivityMinutesAgo": 14, - "assignedOperator": "sales_lead", - "readBy": "sales_lead", - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Нужен расчёт лицензий на 3 производственных участка. Скидка за объём есть?" } - ] - }, - { - "key": "vektor-closed-deal", - "channel": "vektor-sales", - "connection": "vektor-sales-tg", - "contact": "e.zaitseva", - "externalChatId": "demo-vektor-closed-deal", - "controlMode": "HUMAN", - "expectedResponder": "NOBODY", - "lifecycle": "CLOSED", - "assignedOperator": "sales_lead", - "readBy": "sales_lead", - "lastActivityMinutesAgo": 1800, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Добрый день! Берём Вектор на основной цех плюс продление поддержки." }, - { "externalId": "m2", "author": "OPERATOR", "authorUser": "sales_lead", "text": "Отлично, Екатерина! Готовлю счёт на лицензию и год поддержки. Пришлю на почту сегодня." }, - { "externalId": "m3", "author": "CONTACT", "text": "Спасибо, жду." } - ] - }, - { - "key": "reper-lead-active", - "channel": "reper-sales", - "connection": "reper-sales-tg", - "contact": "p.gusev", - "externalChatId": "demo-reper-lead-active", - "controlMode": "AI", - "expectedResponder": "CUSTOMER", - "lastActivityMinutesAgo": 8, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Здравствуйте! У нас автопарк 60 машин, интересна телематика Репер." }, - { "externalId": "m2", "author": "AI", "text": "Здравствуйте, Павел! Для 60 единиц выгоднее годовая подписка. Уточните, пожалуйста, типы транспорта — есть ли спецтехника?" } - ] - }, - { - "key": "reper-quote", - "channel": "reper-sales", - "connection": "reper-sales-max", - "contact": "v.lebedeva", - "externalChatId": "demo-reper-quote", - "controlMode": "PAUSED", - "expectedResponder": "OPERATOR", - "lastActivityMinutesAgo": 22, - "assignedOperator": "sales_junior", - "readBy": "sales_junior", - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Пришлите КП на телематику для парка спецтехники, 32 единицы." } - ] - }, - { - "key": "site-general", - "channel": "site-main", - "connection": "site-web", - "contact": "o.frolova", - "externalChatId": "demo-site-general", - "controlMode": "AI", - "expectedResponder": "AI", - "lastActivityMinutesAgo": 1, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Здравствуйте! Не пойму, какой продукт мне подходит — у меня небольшой завод." }, - { "externalId": "m2", "author": "AI", "text": "Здравствуйте! Подскажу. Если нужно следить за оборудованием в цеху — это Вектор. Если техника и автопарк — Репер. Расскажите чуть подробнее о задаче?" } - ] - }, - { - "key": "site-partner", - "channel": "site-main", - "connection": "site-tg", - "contact": "a.makarov", - "externalChatId": "demo-site-partner", - "controlMode": "HUMAN", - "expectedResponder": "OPERATOR", + "key": "maria_delivery", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", "lifecycle": "OPEN", - "lastActivityMinutesAgo": 35, - "assignedOperator": "admin", - "readBy": "admin", - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Мы системный интегратор, обсуждаем партнёрство по вашим продуктам." }, - { "externalId": "m2", "author": "OPERATOR", "authorUser": "admin", "text": "Антон, спасибо за интерес! Подключу вас к партнёрской программе, отправлю условия на почту." } - ] - }, - { - "key": "site-spam", - "channel": "site-main", - "connection": "site-max", - "contact": "r.borisov", - "externalChatId": "demo-site-spam", - "controlMode": "AI", - "expectedResponder": "NOBODY", - "lifecycle": "SPAM", - "lastActivityMinutesAgo": 120, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Куплю ваш бизнес, пишите на этот номер" } - ] - }, - { - "key": "reper-support-identity", - "channel": "reper-support", - "connection": "reper-support-web", - "contact": "p.gusev", - "supportIdentitySnapshot": "reper-p-gusev", - "externalChatId": "demo-reper-support-identity", "controlMode": "HUMAN", "expectedResponder": "OPERATOR", - "lastActivityMinutesAgo": 6, - "assignedOperator": "support_lead", - "readBy": "support_lead", - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "Не отображается топливный датчик на одной из машин." }, - { "externalId": "m2", "author": "OPERATOR", "authorUser": "support_lead", "text": "Проверяю статус опроса контроллера. Какой госномер машины?" }, - { "externalId": "m3", "author": "CONTACT", "text": "В 477 ОР 63" } - ] - }, - { - "key": "vektor-support-identity", - "channel": "vektor-support", - "connection": "vektor-support-web", - "contact": "n.kuznetsova", - "supportIdentitySnapshot": "vektor-n-kuznetsova", - "externalChatId": "demo-vektor-support-identity", - "controlMode": "AI", - "expectedResponder": "AI", - "lifecycle": "OPEN", - "lastActivityMinutesAgo": 11, - "messages": [ - { "externalId": "m1", "author": "CONTACT", "text": "После обновления Вектора пропали тревоги по давлению." }, - { "externalId": "m2", "author": "AI", "text": "Проверю настройки порогов после обновления. Уточните версию сборки в правом нижнем углу консоли." } - ] - }, - { - "key": "support-email-thread", - "channel": "site-main", - "connection": "support-email", - "contact": "f.grigorev", - "externalChatId": "f.grigorev@technosoft.ru", - "controlMode": "HUMAN", - "expectedResponder": "OPERATOR", - "lifecycle": "OPEN", - "lastActivityMinutesAgo": 42, - "assignedOperator": "support_engineer", - "readBy": "support_engineer", - "transportMeta": { "subject": "Вопрос по продлению поддержки Вектор", "last_message_id": "" }, + "assignedOperator": "anna", + "group": "consultants", + "priority": "HIGH", + "labels": [ + "delivery", + "urgent" + ], + "note": "Заказ 4471 — комплект штор. Постоянный клиент с августа, предпочитает Telegram.", + "createdMinutesAgo": 95, + "lastActivityMinutesAgo": 4, + "readBy": [ + "anna" + ], + "previousConversation": "maria_first", "messages": [ { - "externalId": "", "author": "CONTACT", - "text": "Добрый день!\n\nПодскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.\n\nС уважением,\nФёдор Григорьев\nООО «ТехноСофт»", - "contentHtml": "

Добрый день!

Подскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.

С уважением,
Фёдор Григорьев
ООО «ТехноСофт»

" + "text": "Здравствуйте! Заказ 4471 — когда доставка? Обещали до среды.", + "minutesAgo": 95 + }, + { + "author": "AI", + "text": "Здравствуйте, Мария. Доставка в Казань занимает 3–5 рабочих дней с момента отправки. Заказ 4471 сейчас на этапе пошива — отправим ориентировочно в четверг.", + "minutesAgo": 94 + }, + { + "author": "CONTACT", + "text": "А можно ускорить? Нужно к субботе.", + "minutesAgo": 90 + }, + { + "author": "AI", + "text": "Понимаю. Ускоренная доставка возможна, но её условия лучше уточнить у специалиста — передаю вопрос коллегам.", + "minutesAgo": 90 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: клиент просит ускорить доставку", + "minutesAgo": 90 }, { - "externalId": "demo-email-reply-1", "author": "OPERATOR", - "authorUser": "support_engineer", - "text": "Здравствуйте, Фёдор! Продление поддержки на 12 месяцев для лицензии В-4712 — 189 000 ₽. Подготовлю счёт, на какую почту направить?" + "authorUser": "anna", + "text": "Мария, добрый день! Анна Ким, консультант. Уточняю у производства, можно ли отправить завтра.", + "minutesAgo": 48 + }, + { + "author": "SYSTEM", + "text": "Анна Ким взяла диалог", + "minutesAgo": 48 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "maria-hurry.ogg", + "contentType": "audio/ogg", + "durationSeconds": 14, + "transcriptStatus": "READY", + "transcript": "Анна, спасибо! Если получится отправить завтра, будет идеально — в субботу приезжают гости, хочу успеть повесить." + }, + "minutesAgo": 30 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "Здравствуйте, Мария. Заказ 4471 собран и передан курьеру, доставка сегодня до 20:00. Курьер позвонит за час.", + "minutesAgo": 6 + }, + { + "author": "CONTACT", + "text": "Спасибо, понял", + "minutesAgo": 4 + } + ] + }, + { + "key": "irina_status", + "agent": "consultant", + "connection": "tg", + "contact": "irina", + "identity": "irina_tg", + "externalChatId": "482913002", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "support", + "priority": "MEDIUM", + "labels": [ + "delivery" + ], + "note": "Постоянный клиент, заказ 4390. Просила писать на почту, не звонить.", + "createdMinutesAgo": 130, + "lastActivityMinutesAgo": 110, + "readBy": [], + "previousConversation": "irina_first", + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте. Заказ 4390 оплачен неделю назад, статус не меняется. Что происходит?", + "minutesAgo": 130 + }, + { + "author": "AI", + "text": "Здравствуйте, Ирина. Вижу заказ 4390 в статусе «В производстве». Уточню сроки у коллег — передаю вопрос.", + "minutesAgo": 129 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: вопрос по статусу заказа", + "minutesAgo": 129 + }, + { + "author": "CONTACT", + "text": "Хочу поговорить с человеком", + "minutesAgo": 110 + } + ] + }, + { + "key": "olga_return", + "agent": "consultant", + "connection": "max", + "contact": "olga", + "identity": "olga_max", + "externalChatId": "max-88214", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "consultants", + "priority": "HIGH", + "labels": [ + "return" + ], + "note": "", + "createdMinutesAgo": 22, + "lastActivityMinutesAgo": 6, + "readBy": [], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте! Получила шторы, но цвет не тот, что на сайте.", + "minutesAgo": 22 + }, + { + "author": "AI", + "text": "Здравствуйте, Ольга. Сожалею. Возврат возможен в течение 14 дней — передаю вопрос сотруднику.", + "minutesAgo": 21 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: жалоба на качество", + "minutesAgo": 21 + }, + { + "author": "CONTACT", + "text": "Можно оформить возврат? Я бы хотела заменить на бежевый, если он есть в наличии, а не возвращать деньги.", + "minutesAgo": 12 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "olga-color.ogg", + "contentType": "audio/ogg", + "durationSeconds": 9, + "transcriptStatus": "NONE", + "transcript": "" + }, + "minutesAgo": 6 + } + ] + }, + { + "key": "sergey_address", + "agent": "support", + "connection": "web_account", + "contact": "sergey", + "identity": "sergey_web", + "externalChatId": "web-sess-4402a", + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "group": "support", + "priority": "NONE", + "labels": [ + "delivery" + ], + "note": "", + "createdMinutesAgo": 40, + "lastActivityMinutesAgo": 33, + "readBy": [ + "artem" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, хочу изменить адрес доставки по заказу 4402", + "minutesAgo": 40 + }, + { + "author": "AI", + "text": "Здравствуйте, Сергей. Напишите новый адрес — поменяю.", + "minutesAgo": 40 + }, + { + "author": "CONTACT", + "text": "Казань, ул. Баумана 12, кв. 40", + "minutesAgo": 36 + }, + { + "author": "AI", + "text": "Сейчас уточню у коллег, какой адрес указан в кабинете.", + "minutesAgo": 36 + }, + { + "author": "AI", + "text": "Готово, адрес обновлён. Хорошего дня!", + "minutesAgo": 34 + }, + { + "author": "CONTACT", + "kind": "contact", + "text": "+7 903 445-90-12", + "minutesAgo": 33 + } + ], + "supportIdentitySnapshot": "sergey_account" + }, + { + "key": "darya_measure", + "agent": "consultant", + "connection": "tg", + "contact": "darya", + "identity": "darya_tg", + "externalChatId": "482913005", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "dmitry", + "group": "consultants", + "priority": "LOW", + "labels": [], + "note": "Спрашивала о тканях и сроках пошива. Передан каталог.", + "createdMinutesAgo": 1500, + "lastActivityMinutesAgo": 1380, + "readBy": [ + "dmitry" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Добрый день. У вас можно заказать шторы по своим размерам?", + "minutesAgo": 1500 + }, + { + "author": "AI", + "text": "Добрый день! Да, шьём по индивидуальным размерам. Подскажите ширину и высоту окна — рассчитаю стоимость.", + "minutesAgo": 1499 + }, + { + "author": "CONTACT", + "text": "Карниз 3 метра, высота 2,6. Хочу блэкаут, серый или графит.", + "minutesAgo": 1490 + }, + { + "author": "AI", + "text": "Блэкаут «Норд» есть в графите и дымчато-сером. Две полотна на карниз 3 м с коэффициентом 1.5 — примерно 9 270 ₽ с пошивом и лентой. Передаю консультанту, он пришлёт образцы.", + "minutesAgo": 1489 + }, + { + "author": "SYSTEM", + "text": "Дмитрий Орлов взял диалог", + "minutesAgo": 1420 + }, + { + "author": "OPERATOR", + "authorUser": "dmitry", + "kind": "voice", + "voice": { + "file": "dmitry-samples.ogg", + "contentType": "audio/ogg", + "durationSeconds": 18, + "transcriptStatus": "READY", + "transcript": "Дарья, добрый день, это Дмитрий из ателье. Отправлю вам три образца блэкаута: графит, дымчатый и тёмно-серый. Напишите адрес с индексом, уйдут завтра почтой." + }, + "minutesAgo": 1400 + }, + { + "author": "CONTACT", + "text": "Уточняла размеры окон, получила расчёт стоимости, оформила заказ.", + "minutesAgo": 1380 + } + ] + }, + { + "key": "guest_curtains", + "agent": "consultant", + "connection": "web", + "webGuest": true, + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 9, + "lastActivityMinutesAgo": 8, + "readBy": [], + "messages": [ + { + "author": "CONTACT", + "text": "Сколько стоит тюль на окно 2 метра?", + "minutesAgo": 9 + }, + { + "author": "AI", + "text": "Здравствуйте! Тюль-вуаль на карниз 2 м с коэффициентом 2.0 — 4 м ткани по 390 ₽ плюс пошив 350 ₽/м: около 2 960 ₽. Минимальный заказ 3 000 ₽ — можно добавить шторную ленту. Подсказать по другим тканям?", + "minutesAgo": 8 + } + ] + }, + { + "key": "polina_password", + "agent": "support", + "connection": "email", + "contact": "polina", + "identity": "polina_email", + "externalChatId": "polina.rakitina@mail.ru", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "support", + "priority": "MEDIUM", + "labels": [], + "note": "", + "createdMinutesAgo": 240, + "lastActivityMinutesAgo": 200, + "readBy": [], + "transportMeta": { + "subject": "Не приходит письмо для сброса пароля" + }, + "messages": [ + { + "author": "CONTACT", + "text": "Не приходит письмо для сброса пароля в личном кабинете", + "contentHtml": "

Здравствуйте!

Пытаюсь восстановить пароль от личного кабинета на atelie-nord.ru — письмо не приходит уже час. Адрес правильный, проверила.

С уважением,
Полина Ракитина

", + "minutesAgo": 240 + }, + { + "author": "AI", + "text": "Проверьте папку «Спам». Если письма нет, передам запрос сотруднику.", + "contentHtml": "

Здравствуйте, Полина!

Письмо приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Проверьте, пожалуйста, папку «Спам». Если письма нет — передам запрос сотруднику.

", + "minutesAgo": 238 + }, + { + "author": "CONTACT", + "text": "В спаме тоже нет. Помогите, пожалуйста.", + "contentHtml": "

В спаме тоже нет. Помогите, пожалуйста.

", + "minutesAgo": 200 + }, + { + "author": "SYSTEM", + "text": "AI передал диалог: клиент просит сотрудника", + "minutesAgo": 200 + } + ] + }, + { + "key": "viktor_wholesale", + "agent": "consultant", + "connection": "tg", + "contact": "viktor", + "identity": "viktor_tg", + "externalChatId": "482913006", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "svetlana", + "group": "consultants", + "priority": "LOW", + "labels": [ + "vip", + "wholesale" + ], + "note": "Закупщик текстиля для гостиницы. Просил писать на почту, не звонить.", + "createdMinutesAgo": 4300, + "lastActivityMinutesAgo": 2900, + "readBy": [ + "svetlana", + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Добрый день. Гостиница «Дом текстиля», 46 номеров. Нужны блэкаут и тюль на все окна, единый размер 1.8 × 2.5. Есть оптовые условия?", + "minutesAgo": 4300 + }, + { + "author": "AI", + "text": "Добрый день, Виктор! Оптовые условия обсуждает старший консультант — передаю диалог.", + "minutesAgo": 4299 + }, + { + "author": "SYSTEM", + "text": "Светлана Петрова взяла диалог", + "minutesAgo": 4200 + }, + { + "author": "OPERATOR", + "authorUser": "svetlana", + "text": "Виктор, здравствуйте! Светлана Петрова. На 46 комплектов даём 12% на ткань и пошив, срок 3 недели. Отправлю КП на почту сегодня.", + "minutesAgo": 4190 + }, + { + "author": "CONTACT", + "text": "Отлично, жду КП. Почта на визитке.", + "minutesAgo": 2900 + } + ] + }, + { + "key": "maria_first", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "anna", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdDaysAgo": 34, + "lastActivityDaysAgo": 33, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте! Заказала у вас комплект штор, номер заказа 4471. Подскажите, сколько занимает доставка в Казань?", + "daysAgo": 34 + }, + { + "author": "AI", + "text": "Здравствуйте, Мария! Доставка в Казань СДЭК — 3–5 рабочих дней с момента отправки, трек-номер придёт на почту.", + "daysAgo": 34 + }, + { + "author": "CONTACT", + "text": "Спасибо! А можно перенести на завтра утро? Сегодня меня не будет дома.", + "daysAgo": 33 + }, + { + "author": "SYSTEM", + "text": "Анна Ким взяла диалог", + "daysAgo": 33 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "Конечно. Перенесла доставку на завтра, 10:00–13:00. Подтвердите, пожалуйста, что интервал подходит.", + "daysAgo": 33 + }, + { + "author": "CONTACT", + "text": "Подходит, спасибо!", + "daysAgo": 33 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · вела Анна Ким", + "daysAgo": 33 + } + ] + }, + { + "key": "irina_first", + "agent": "consultant", + "connection": "max", + "contact": "irina", + "identity": "irina_max", + "externalChatId": "max-77103", + "lifecycle": "CLOSED", + "controlMode": "AI", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdDaysAgo": 60, + "lastActivityDaysAgo": 60, + "readBy": [ + "anna", + "dmitry" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, какие ткани есть для спальни, чтобы полностью затемняли?", + "daysAgo": 60 + }, + { + "author": "AI", + "text": "Здравствуйте, Ирина! Для полного затемнения подойдёт блэкаут «Норд» — 100% затемнение, 14 цветов, 1 490 ₽/м. Могу отправить образцы почтой бесплатно.", + "daysAgo": 60 + }, + { + "author": "CONTACT", + "text": "Спасибо, понятно, подумаю.", + "daysAgo": 60 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · AI", + "daysAgo": 59 + } + ] + }, + { + "key": "olga_archived", + "agent": "consultant", + "connection": "max", + "contact": "olga", + "identity": "olga_max", + "externalChatId": "max-88214", + "lifecycle": "CLOSED", + "controlMode": "AI", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "archivedDaysAgo": 5, + "createdDaysAgo": 12, + "lastActivityDaysAgo": 12, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Здравствуйте, работаете ли вы в выходные?", + "daysAgo": 12 + }, + { + "author": "AI", + "text": "Здравствуйте! Консультанты отвечают ежедневно с 10:00 до 20:00, производство — по будням.", + "daysAgo": 12 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · AI", + "daysAgo": 11 + } + ] + }, + { + "key": "spam_credit", + "agent": "consultant", + "connection": "tg", + "contact": "spammer", + "identity": "spam_tg", + "externalChatId": "900000001", + "lifecycle": "SPAM", + "controlMode": "PAUSED", + "expectedResponder": "NOBODY", + "group": "consultants", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 600, + "lastActivityMinutesAgo": 600, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "ВЫГОДНЫЙ КРЕДИТ ДЛЯ БИЗНЕСА до 5 000 000 ₽ без справок! Жми: credit-fast24.example", + "minutesAgo": 600 + }, + { + "author": "SYSTEM", + "text": "Анна Ким пометила диалог как спам", + "minutesAgo": 590 + } + ] + }, + { + "key": "portal_sergey", + "agent": "support", + "connection": "web_account", + "contact": "sergey", + "identity": "sergey_web", + "supportIdentitySnapshot": "sergey_account", + "externalChatId": "portal-sergey-1", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "artem", + "group": "support", + "priority": "NONE", + "labels": [], + "note": "Авторизованное обращение из личного кабинета (портал поддержки).", + "createdDaysAgo": 6, + "lastActivityDaysAgo": 6, + "readBy": [ + "artem" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Оплатил заказ 4402 по СБП, а статус «Ожидает оплаты». Чек прикладываю.", + "daysAgo": 6 + }, + { + "author": "AI", + "text": "Здравствуйте, Сергей. Платёж по СБП отражается до 30 минут. Если статус не изменится — передам сотруднику.", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Артём Гусев взял диалог", + "daysAgo": 6 + }, + { + "author": "OPERATOR", + "authorUser": "artem", + "text": "Сергей, платёж нашёл и провёл вручную — заказ 4402 в статусе «В производстве». Спасибо за терпение!", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Диалог закрыт · вёл Артём Гусев", + "daysAgo": 6 } ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg new file mode 100644 index 0000000..8fdef99 Binary files /dev/null and b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/darya-volkova.jpg differ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg new file mode 100644 index 0000000..0e1b698 Binary files /dev/null and b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/irina-levina.jpg differ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg new file mode 100644 index 0000000..fbdf4ea Binary files /dev/null and b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/maria-sokolova.jpg differ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg new file mode 100644 index 0000000..5c7e8da Binary files /dev/null and b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/olga-melnik.jpg differ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg new file mode 100644 index 0000000..61a0671 Binary files /dev/null and b/apps/backend/hub_platform/identity/demo_seed/data/media/avatars/sergey-krylov.jpg differ diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt b/apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt new file mode 100644 index 0000000..36fbd05 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/dostavka-i-oplata.txt @@ -0,0 +1,13 @@ +ДОСТАВКА И ОПЛАТА — памятка консультанта + +Пошив: 5–7 рабочих дней после оплаты. Срочный (3 дня): +30% к пошиву, только ткань в наличии. + +Санкт-Петербург, курьер: 400 ₽, бесплатно от 10 000 ₽. +Интервалы: 10:00–13:00, 13:00–17:00, 17:00–20:00. Курьер звонит за час. +Перенос интервала бесплатно до 18:00 накануне. + +Россия, СДЭК: до ПВЗ или до двери, 3–5 рабочих дней с момента отправки. +Трек-номер уходит на почту клиента в день отправки, до 19:00. +Казань, Москва, Нижний Новгород — обычно 3 дня. + +Оплата: 100% при заказе (карта, СБП) или 50/50 для заказов от 30 000 ₽. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf new file mode 100644 index 0000000..e83ea50 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.pdf @@ -0,0 +1,32 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 496 >> +stream +BT /F1 12 Tf 50 780 Td 16 TL (Atelie Nord - Fabric catalogue 2026) Tj T* () Tj T* (Curtain fabrics width 280-300 cm) Tj T* (Blackout Nord - 100% dimout - 1490 RUB/m - 14 colours) Tj T* (Blended linen - 890 RUB/m - 9 colours) Tj T* (Velvet Verona - 2190 RUB/m - 6 colours) Tj T* (Canvas - 690 RUB/m - 12 colours) Tj T* () Tj T* (Sheer fabrics) Tj T* (Voile - 390 RUB/m) Tj T* (Greek mesh - 520 RUB/m) Tj T* (Linen sheer - 740 RUB/m) Tj T* () Tj T* (Samples: up to 5 pieces, free by post.) Tj T* ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000788 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +858 +%%EOF diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt new file mode 100644 index 0000000..355c99e --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/katalog-tkanej-2026.txt @@ -0,0 +1,14 @@ +Каталог тканей 2026 — Ателье Норд + +Портьерные ткани (ширина 280–300 см) +Блэкаут «Норд» — 100% затемнение — 1 490 ₽/м — 14 цветов +Лён смесовый — 890 ₽/м — 9 цветов +Бархат «Верона» — 2 190 ₽/м — 6 цветов +Канвас — 690 ₽/м — 12 цветов + +Тюль +Вуаль — 390 ₽/м +Сетка «Грек» — 520 ₽/м +Лён-тюль — 740 ₽/м + +Образцы: до 5 штук почтой бесплатно. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md b/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md new file mode 100644 index 0000000..2e571b3 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/raschet-stoimosti.md @@ -0,0 +1,32 @@ +# Расчёт стоимости пошива штор + +## Формула + +**Стоимость = ткань + пошив + фурнитура** + +| Составляющая | Как считаем | +| --- | --- | +| Ткань | метраж × цена за метр (см. каталог тканей) | +| Пошив портьер | 450 ₽ за погонный метр готовой ширины | +| Пошив тюля | 350 ₽ за погонный метр готовой ширины | +| Люверсы | 90 ₽ за штуку (шаг 18 см) | +| Шторная лента | 120 ₽ за метр | + +## Коэффициент сборки + +- Плотные портьеры — 1.5 +- Лён, канвас — 1.8 +- Тюль, вуаль — 2.0–2.5 + +Ширина ткани = ширина карниза × коэффициент. Высота ткани = высота изделия + 15 см на подгиб. + +## Пример + +Карниз 3 м, высота 2.6 м, блэкаут «Норд» 1 490 ₽/м, две полотна. + +- Ткань: 3 × 1.5 = 4.5 м × 1 490 = 6 705 ₽ +- Пошив: 4.5 × 450 = 2 025 ₽ +- Лента: 4.5 × 120 = 540 ₽ +- **Итого: 9 270 ₽** + +Минимальный заказ — 3 000 ₽. Замер по Санкт-Петербургу бесплатно при заказе от 15 000 ₽. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md b/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md deleted file mode 100644 index 5630993..0000000 --- a/apps/backend/hub_platform/identity/demo_seed/data/media/reper-telematics-spec.md +++ /dev/null @@ -1,30 +0,0 @@ -# Репер — спецификация телематики - -## Поддерживаемые протоколы - -| Протокол | Назначение | -|---|---| -| Teltonika | Бортовые контроллеры серии FMC/FMB | -| Wialon IPS | Совместимые трекеры сторонних производителей | -| Galileosky | Контроллеры Galileosky v2/v3 | - -## Совместимое оборудование - -- **Бортовые контроллеры:** Teltonika FMB130/FMB140, Galileosky Base/Smart. -- **Датчики уровня топлива (ДУТ):** Омником, Эскорт, LLS. -- **Датчики моточасов:** входы дискретных и аналоговых датчиков. - -## Частота опроса - -- Положение: раз в 30 секунд при движении, раз в 5 минут на стоянке. -- Топливо: непрерывно, усреднение за минуту. - -## Атрибутика единицы техники - -- Госномер, модель, тип (грузовик / спецтехника / прицеп). -- Привязка к бортовому контроллеру по идентификатору (IMEI). - -## Ограничения - -- Минимальный интервал данных — 10 секунд. -- Один бортовой контроллер = одна единица техники в подписке. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md b/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md deleted file mode 100644 index e59aeec..0000000 --- a/apps/backend/hub_platform/identity/demo_seed/data/media/vektor-deploy-guide.md +++ /dev/null @@ -1,26 +0,0 @@ -# Вектор — руководство по развёртыванию коробочной лицензии - -## Системные требования - -- **ОС:** Linux (Ubuntu 22.04 LTS / РЕД ОС 7.3) -- **Процессор:** 4 ядра -- **ОЗУ:** 8 ГБ -- **Диск:** 80 ГБ SSD -- **Сеть:** доступ к контроллерам оборудования по OPC UA (4840) и Modbus TCP (502) - -## Порядок установки - -1. Разверните дистрибутив из архива `vektor-server-.tar.gz`. -2. Укажите лицензионный ключ в `/opt/vektor/etc/license.conf`. -3. Запустите сервис: `systemctl start vektor-server`. -4. Откройте веб-консоль на `https://<сервер>:8443` и задайте пароль администратора. - -## Подключение контроллеров - -- OPC UA: добавьте endpoint в разделе «Источники → OPC UA». -- Modbus TCP: укажите IP и список регистров в «Источники → Modbus». - -## Проверка - -После подключения зайдите в «Живые данные» — метрики должны обновляться в течение секунды. -Если данных нет, проверьте сетевую связность и брандмауэр. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md b/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md new file mode 100644 index 0000000..3cc64a4 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/data/media/voice/README.md @@ -0,0 +1,15 @@ +# Голосовые сообщения демо + +Сюда кладутся аудиофайлы, на которые ссылается `data/conversations.json` +(`messages[].voice.file`). Речь должна быть человеческой и совпадать с текстом +`transcript` соответствующего сообщения — расшифровка показывается рядом с плеером. + +| Файл | Кто говорит | Длительность | Текст | +| --- | --- | --- | --- | +| `maria-hurry.ogg` | клиентка, Мария | ~14 с | «Анна, спасибо! Если получится отправить завтра, будет идеально — в субботу приезжают гости, хочу успеть повесить.» | +| `olga-color.ogg` | клиентка, Ольга | ~9 с | любой текст: сообщение показывается нерасшифрованным (кнопка «Расшифровать») | +| `dmitry-samples.ogg` | сотрудник, Дмитрий | ~18 с | «Дарья, добрый день, это Дмитрий из ателье. Отправлю вам три образца блэкаута: графит, дымчатый и тёмно-серый. Напишите адрес с индексом, уйдут завтра почтой.» | + +Формат — OGG/Opus (как в Telegram и MAX), моно, 16–48 кГц. Лицензия — совместимая +с открытой публикацией (CC0/CC BY или собственная запись). Если файла нет, сид +пропускает сообщение с предупреждением в логе — остальные данные ставятся. diff --git a/apps/backend/hub_platform/identity/demo_seed/data/operations.json b/apps/backend/hub_platform/identity/demo_seed/data/operations.json index 8d28898..098cc07 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/operations.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/operations.json @@ -1,59 +1,25 @@ { "schemaVersion": 1, "calls": [ - { - "conversation": "reper-support-identity", - "initiatedBy": "support_lead", - "contactIdentity": "p.gusev-tg", - "status": "ENDED", - "endedBy": "STAFF", - "minutesAgo": 30, - "durationSeconds": 240, - "deliveryConnection": "reper-support-web", - "metrics": { - "connectionType": "DIRECT", - "localCandidateType": "host", - "remoteCandidateType": "srflx", - "roundTripMs": 42 - } - }, - { - "conversation": "vektor-queue", - "initiatedBy": "sales_lead", - "contactIdentity": "s.orlov-tg", - "status": "DECLINED", - "minutesAgo": 90 - } + {"key": "call_maria", "conversation": "maria_first", "initiatedBy": "anna", "contactIdentity": "maria_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "ENDED", "endedBy": "STAFF", "daysAgo": 33, "durationSeconds": 262, "metrics": {"connectionType": "RELAY", "localCandidateType": "relay", "remoteCandidateType": "relay", "roundTripMs": 64}}, + {"key": "call_darya", "conversation": "darya_measure", "initiatedBy": "dmitry", "contactIdentity": "darya_tg", "deliveryConnection": "tg", "kind": "VIDEO", "status": "ENDED", "endedBy": "CUSTOMER", "minutesAgo": 1395, "durationSeconds": 540, "metrics": {"connectionType": "DIRECT", "localCandidateType": "srflx", "remoteCandidateType": "srflx", "roundTripMs": 31}}, + {"key": "call_olga_missed", "conversation": "olga_archived", "initiatedBy": "anna", "contactIdentity": "olga_max", "deliveryConnection": "max", "kind": "AUDIO", "status": "MISSED", "daysAgo": 12}, + {"key": "call_viktor_declined", "conversation": "viktor_wholesale", "initiatedBy": "svetlana", "contactIdentity": "viktor_tg", "deliveryConnection": "tg", "kind": "AUDIO", "status": "DECLINED", "endedBy": "CUSTOMER", "minutesAgo": 4185}, + {"key": "call_sergey_expired", "conversation": "portal_sergey", "initiatedBy": "artem", "contactIdentity": "sergey_web", "deliveryConnection": "web", "kind": "AUDIO", "status": "EXPIRED", "daysAgo": 6, "invite": {"deliveryStatus": "DELIVERED", "expiredMinutesAfter": 2}} ], "notifications": [ - { - "type": "DIALOG_WAITING", - "level": "WARNING", - "title": "Диалог ожидает оператора", - "body": "Сергей Орлов ждёт ответа в канале продаж Вектора.", - "targetRoute": "chat", - "targetId": "vektor-queue", - "audience": "OPERATORS", - "sourceId": "vektor-queue", - "readBy": "sales_lead" - }, - { - "type": "DIALOG_NEW_MESSAGE", - "level": "INFO", - "title": "Новое сообщение в диалоге", - "body": "Наталья Кузнецова написала в продажи Вектора.", - "targetRoute": "chat", - "targetId": "vektor-lead-active", - "audience": "OPERATORS", - "sourceId": "vektor-lead-active" - }, - { - "type": "INTEGRATION_ERROR", - "level": "CRITICAL", - "title": "Проблема с интеграцией", - "body": "Подключение Репер MAX не проверено. Данные могут не доходить.", - "audience": "OWNER", - "sourceId": "reper-sales-max" - } + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ирина Левина ждёт оператора", "body": "Заказ 4390: клиент попросил человека. Ждёт 1 ч 50 мин.", "targetRoute": "chat", "targetConversation": "irina_status", "audience": "ALL", "minutesAgo": 110}, + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Ольга Мельник ждёт оператора", "body": "Возврат: цвет не совпал с сайтом.", "targetRoute": "chat", "targetConversation": "olga_return", "audience": "ALL", "minutesAgo": 21}, + {"type": "DIALOG_WAITING", "level": "WARNING", "title": "Полина Ракитина ждёт оператора", "body": "Почта: не приходит письмо для сброса пароля.", "targetRoute": "chat", "targetConversation": "polina_password", "audience": "ALL", "minutesAgo": 200, "readBy": ["anna", "artem"]}, + {"type": "DIALOG_NEW_MESSAGE", "level": "INFO", "title": "Новое сообщение от Марии Соколовой", "body": "«Спасибо, понял»", "targetRoute": "chat", "targetConversation": "maria_delivery", "audience": "USER", "recipientUser": "anna", "minutesAgo": 4}, + {"type": "INTEGRATION_ERROR", "level": "CRITICAL", "title": "Telegram · бот доставки: ошибка подключения", "body": "401 Unauthorized: bot token was revoked in @BotFather. Проверьте токен в настройках подключения.", "targetRoute": "settings", "audience": "OWNER", "minutesAgo": 4200}, + {"type": "RELEASE_PUBLISHED", "level": "SUCCESS", "title": "Обновление Chatbolls: голосовые сообщения", "body": "Операторы могут отправлять голосовые в Telegram и MAX, входящие расшифровываются одной кнопкой.", "targetRoute": "chat", "audience": "ALL", "daysAgo": 2, "readBy": ["anna", "dmitry", "svetlana"]} + ], + "messengerBindings": [ + {"user": "anna", "connection": "tg", "externalChatId": "482900010", "pushTypes": ["DIALOG_WAITING", "DIALOG_NEW_MESSAGE", "INTEGRATION_ERROR"]}, + {"user": "svetlana", "connection": "tg", "externalChatId": "482900011", "pushTypes": ["DIALOG_WAITING"]} + ], + "messengerBindingCodes": [ + {"user": "dmitry", "connection": "tg", "code": "DEMO-7K2P", "expiresInMinutes": 25} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/organization.json b/apps/backend/hub_platform/identity/demo_seed/data/organization.json index dd590c8..e9f19b8 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/organization.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/organization.json @@ -1,82 +1,85 @@ { "schemaVersion": 1, - "organization": { - "slug": "severnaya-verf", - "name": "Северная Верфь", - "timezone": "Europe/Moscow", - "currency": "RUB" - }, + "_comment": "Демо «Ателье Норд» (дизайн-базлайн v2). Ставится в организацию установщика: организация и её владелец здесь не создаются. Пароль всех демо-сотрудников один — см. demoPassword.", + "demoPassword": "Chatbolls-Demo-2026", + "showcaseAccounts": ["anna", "dmitry", "artem"], + "groups": [ + {"key": "consultants", "name": "Консультанты"}, + {"key": "support", "name": "Поддержка"}, + {"key": "operators", "name": "Операторы"} + ], "accounts": [ { - "key": "owner", - "email": "i.volkov@severnayaverf.ru", - "password": "Demo-Owner-2026", - "fullName": "Игорь Волков", - "role": "OWNER", - "positionTitle": "Генеральный директор", - "phone": "+7 812 240 19 00", - "isStaff": true, - "isSuperuser": true - }, - { - "key": "admin", - "email": "e.sokolova@severnayaverf.ru", - "password": "Demo-Admin-2026", - "fullName": "Елена Соколова", + "key": "anna", + "email": "a.kim@atelie-nord.ru", + "fullName": "Анна Ким", "role": "ADMIN", - "positionTitle": "Руководитель клиентского сервиса", - "phone": "+7 812 240 19 01", - "isStaff": true + "positionTitle": "Администратор", + "phone": "+7 921 300-41-17", + "groups": ["consultants", "support"], + "uiTheme": "LIGHT", + "uiAccent": "#1677ff" }, { - "key": "sales_lead", - "email": "d.morozov@severnayaverf.ru", - "password": "Demo-Sales-2026", - "fullName": "Дмитрий Морозов", + "key": "dmitry", + "email": "d.orlov@atelie-nord.ru", + "fullName": "Дмитрий Орлов", "role": "EMPLOYEE", - "positionTitle": "Старший менеджер по продажам", - "phone": "+7 921 330 11 45", - "group": "sales" + "positionTitle": "Консультант", + "phone": "+7 921 300-41-18", + "groups": ["consultants"], + "uiTheme": "DARK", + "uiAccent": "#7c3aed" }, { - "key": "sales_junior", - "email": "k.belaya@severnayaverf.ru", - "password": "Demo-Sales-2026", - "fullName": "Кристина Белая", + "key": "artem", + "email": "a.gusev@atelie-nord.ru", + "fullName": "Артём Гусев", "role": "EMPLOYEE", - "positionTitle": "Менеджер по продажам", - "phone": "+7 921 778 02 19", - "group": "sales" + "positionTitle": "Специалист поддержки", + "phone": "+7 921 300-41-19", + "groups": ["support"], + "uiTheme": "SYSTEM", + "uiAccent": "#0f9d58" }, { - "key": "support_lead", - "email": "a.novikov@severnayaverf.ru", - "password": "Demo-Support-2026", - "fullName": "Андрей Новиков", + "key": "svetlana", + "email": "s.petrova@atelie-nord.ru", + "fullName": "Светлана Петрова", "role": "EMPLOYEE", - "positionTitle": "Старший инженер поддержки", - "phone": "+7 812 240 19 50", - "group": "support" + "positionTitle": "Старший консультант", + "phone": "+7 921 300-41-20", + "groups": ["consultants", "support", "operators"], + "uiTheme": "LIGHT", + "uiAccent": "#d6336c" }, { - "key": "support_engineer", - "email": "m.zhukova@severnayaverf.ru", - "password": "Demo-Support-2026", - "fullName": "Марина Жукова", + "key": "igor", + "email": "i.saveliev@atelie-nord.ru", + "fullName": "Игорь Савельев", "role": "EMPLOYEE", - "positionTitle": "Инженер технической поддержки", - "phone": "+7 812 240 19 51", - "group": "support" + "positionTitle": "Оператор", + "phone": "", + "groups": [], + "totpEnabled": true, + "uiTheme": "SYSTEM", + "uiAccent": "#1677ff" + }, + { + "key": "natalia", + "email": "n.frolova@atelie-nord.ru", + "fullName": "Наталья Фролова", + "role": "EMPLOYEE", + "positionTitle": "Консультант (уволена)", + "phone": "", + "groups": ["consultants"], + "blocked": true, + "uiTheme": "SYSTEM", + "uiAccent": "#1677ff" } ], - "groups": [ - { - "key": "sales", - "name": "Операторы" - }, - { - "key": "support", - "name": "Поддержка" - } + "invitations": [ + {"email": "v.kotova@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": 6}, + {"email": "old.invite@atelie-nord.ru", "role": "EMPLOYEE", "createdBy": "anna", "expiresInDays": -2} ] } diff --git a/apps/backend/hub_platform/identity/demo_seed/data/support.json b/apps/backend/hub_platform/identity/demo_seed/data/support.json index d7e2b04..d7dc78e 100644 --- a/apps/backend/hub_platform/identity/demo_seed/data/support.json +++ b/apps/backend/hub_platform/identity/demo_seed/data/support.json @@ -1,105 +1,46 @@ { "schemaVersion": 1, "contracts": [ - { "key": "vektor-contract", "product": "vektor", "code": "vektor.support.v1", "version": 1, "status": "ACTIVE" }, - { "key": "reper-contract", "product": "reper", "code": "reper.support.v1", "version": 1, "status": "ACTIVE" } + {"key": "site_contract", "code": "SITE-2026", "product": "atelie-nord-site", "version": 1, "status": "ACTIVE"} ], "identitySnapshots": [ { - "key": "vektor-n-kuznetsova", - "contract": "vektor-contract", - "subjectKey": "vektor-customer:nkuznetsova", - "accountKey": "plant-4712", - "displayName": "Наталья Кузнецова — ООО «Литейный завод №4»", - "displayEmail": "n.kuznetsova@lz4.ru", - "operatorContext": { "license_id": "V-4712", "site": "Литейный цех №4, Санкт-Петербург" }, - "aiContext": { "product": "Вектор", "version": "3.4" }, - "searchText": "Литейный завод 4 Наталья Кузнецова лицензия V-4712", - "issuedDaysAgo": 2, - "expiresInDays": 28 - }, - { - "key": "reper-p-gusev", - "contract": "reper-contract", - "subjectKey": "reper-customer:pgusev", - "accountKey": "fleet-0091", - "displayName": "Павел Гусев — ООО «ТрансЛогистик»", - "displayEmail": "p.gusev@translogistic.ru", - "operatorContext": { "fleet_id": "TL-0091", "units": 60 }, - "aiContext": { "product": "Репер", "tariff": "year" }, - "searchText": "ТрансЛогистик Павел Гусев автопарк 60", - "issuedDaysAgo": 5, - "expiresInDays": 25 + "key": "sergey_account", + "contract": "site_contract", + "subjectKey": "user:4402", + "accountKey": "acc-4402", + "displayName": "Сергей Крылов", + "displayEmail": "s.krylov@example.ru", + "payload": {"orders": [{"number": "4402", "status": "В производстве", "total": 12480}], "city": "Казань"}, + "operatorContext": {"Клиент с": "апрель 2026", "Заказов": "2", "Последний заказ": "4402 — комплект штор, 12 480 ₽"}, + "aiContext": {"orders": "4402 in production"}, + "searchText": "Сергей Крылов s.krylov@example.ru 4402 Казань", + "issuedDaysAgo": 6, + "expiresInDays": 24 } ], "portal": { - "slug": "reper", - "name": "Репер — центр помощи", + "slug": "atelie-nord", + "name": "Помощь · Ателье Норд", "locale": "ru", "status": "PUBLISHED", - "widgetChannel": "reper-help-widget", - "products": ["reper"], + "widgetConnection": "web", + "accountWidgetConnection": "web_account", + "products": ["atelie-nord-site"], "categories": [ - { "slug": "getting-started", "name": "Начало работы", "description": "Подключение техники и первые шаги в Репер", "sortOrder": 10 }, - { "slug": "devices", "name": "Оборудование и датчики", "description": "Совместимые бортовые контроллеры и датчики", "sortOrder": 20 }, - { "slug": "reports", "name": "Отчёты", "description": "Отчёты по топливу, моточасам и геозонам", "sortOrder": 30 }, - { "slug": "billing", "name": "Оплата и подписка", "description": "Тарифы, счета и продление подписки", "sortOrder": 40 } + {"slug": "zakaz", "name": "Заказ и оплата", "description": "Как оформить заказ, оплата, статусы", "sortOrder": 1}, + {"slug": "dostavka", "name": "Доставка", "description": "Сроки, курьер, СДЭК", "sortOrder": 2}, + {"slug": "vozvrat", "name": "Возврат и обмен", "description": "Условия возврата изделий", "sortOrder": 3}, + {"slug": "uhod", "name": "Уход за изделиями", "description": "Стирка и глажка по типам тканей", "sortOrder": 4} ], "articles": [ - { - "slug": "how-to-connect-first-unit", - "category": "getting-started", - "title": "Как подключить первую единицу техники", - "summary": "Пошаговая инструкция по добавлению транспортного средства в Репер.", - "content": "# Подключение первой единицы техники\n\n1. В разделе «Парк» нажмите «Добавить технику».\n2. Укажите госномер и модель.\n3. Выберите подключенный бортовой контроллер.\n4. Дождитесь первого пакета данных — он придёт в течение нескольких минут.\n\nЕсли данные не появляются дольше 15 минут, проверьте питание контроллера.", - "status": "PUBLISHED", - "helpfulVotes": 14 - }, - { - "slug": "supported-trackers", - "category": "devices", - "title": "Поддерживаемые бортовые контроллеры", - "summary": "Список совместимых контроллеров и протоколов обмена данными.", - "content": "# Поддерживаемые контроллеры\n\nРепер поддерживает устройства по протоколам: Teltonika, Wialon IPS, Galileosky.\n\nПолная спецификация протоколов приведена в базе знаний операторов поддержки.", - "status": "PUBLISHED", - "helpfulVotes": 8 - }, - { - "slug": "fuel-report", - "category": "reports", - "title": "Отчёт по расходу топлива", - "summary": "Как построить отчёт о заправках и сливах топлива.", - "content": "# Отчёт по топливу\n\nОткройте «Отчёты → Топливо», выберите период и технику. Отчёт покажет заправки, сливы и средний расход.\n\nДля корректных данных нужен исправный ДУТ.", - "status": "PUBLISHED", - "helpfulVotes": 11 - }, - { - "slug": "geozone-setup", - "category": "reports", - "title": "Настройка геозон", - "summary": "Создание геозон и контроль въезда/выезда.", - "content": "# Геозоны\n\nВ разделе «Геозоны» создайте область на карте. К ней можно привязать уведомления о въезде и выезде техники.", - "status": "PUBLISHED", - "helpfulVotes": 5 - }, - { - "slug": "tariffs-and-payment", - "category": "billing", - "title": "Тарифы и оплата подписки", - "summary": "Помесячная и годовая оплата, как получить счёт.", - "content": "# Тарифы и оплата\n\nДоступны два варианта оплаты: помесячно и за год. Годовая подписка выгоднее для парков от 50 единиц техники.\n\nСчёт на оплату можно запросить у вашего менеджера или в этом чате.", - "status": "PUBLISHED", - "helpfulVotes": 9 - }, - { - "slug": "change-fleet-size", - "category": "billing", - "title": "Изменение размера парка", - "summary": "Как добавить или убрать технику из подписки.", - "content": "# Изменение парка\n\nПодписка пересчитывается по числу активных единиц техники. Чтобы убрать технику — переведите её в статус «Неактивна».", - "status": "PUBLISHED", - "helpfulVotes": 3 - } + {"slug": "kak-snyat-merki", "category": "zakaz", "title": "Как снять мерки для штор", "summary": "Карниз, высота, припуски — за пять минут", "content": "## Длина карниза\n\nИзмерьте карниз от края до края — не окно. Шторы должны закрывать окно с запасом 15–20 см с каждой стороны.\n\n## Высота\n\nОт крючка карниза (или верха трубы) до желаемой длины: до подоконника минус 1 см, до пола минус 1–2 см, «в пол» с напуском плюс 3–5 см.\n\n## Люверсы\n\nДля штор на люверсах прибавьте 3 см сверху.\n\nСфотографируйте окно целиком и пришлите консультанту — проверим расчёт бесплатно.", "status": "PUBLISHED", "helpfulVotes": 14, "unhelpfulVotes": 1, "publishedDaysAgo": 27}, + {"slug": "sroki-dostavki", "category": "dostavka", "title": "Сроки пошива и доставки", "summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России", "content": "Пошив занимает **5–7 рабочих дней** после согласования расчёта и оплаты.\n\n### По Санкт-Петербургу\n\nКурьер — 400 ₽, бесплатно при заказе от 10 000 ₽. Интервалы 10:00–13:00, 13:00–17:00, 17:00–20:00. Курьер звонит за час.\n\n### По России\n\nСДЭК до пункта выдачи или до двери, 3–5 рабочих дней. Трек-номер приходит на почту в день отправки.", "status": "PUBLISHED", "helpfulVotes": 22, "unhelpfulVotes": 3, "publishedDaysAgo": 26, "secondRevision": {"summary": "5–7 рабочих дней на пошив, 3–5 — на доставку по России. Перенос интервала — бесплатно", "contentAppend": "\n\n### Перенос доставки\n\nИнтервал можно перенести бесплатно до 18:00 накануне — напишите в чат или на info@atelie-nord.ru.", "daysAgo": 9}}, + {"slug": "vozvrat-i-obmen", "category": "vozvrat", "title": "Возврат и обмен изделий", "summary": "Готовые изделия — 14 дней; индивидуальный пошив — переделка при браке", "content": "Готовые изделия из каталога возвращаются в течение **14 дней**, если не были в использовании.\n\nИзделия по индивидуальным размерам возврату не подлежат (ст. 26.1 ЗоЗПП), кроме брака и несоответствия заказу: неверный цвет или размер, дефекты пошива. В этом случае — бесплатная переделка или обмен, срок 5–7 рабочих дней.\n\nДля заявки пришлите номер заказа и фото изделия в чат.", "status": "PUBLISHED", "helpfulVotes": 9, "unhelpfulVotes": 4, "publishedDaysAgo": 25}, + {"slug": "lichnyj-kabinet", "category": "zakaz", "title": "Личный кабинет: вход и восстановление пароля", "summary": "Письмо для сброса приходит в течение 5 минут", "content": "Вход — по e-mail. Письмо для сброса пароля приходит с адреса no-reply@atelie-nord.ru в течение 5 минут. Если письма нет — проверьте «Спам». Повторный запрос доступен через 10 минут.\n\nСтатусы заказа: «Ожидает оплаты» → «В производстве» → «Готов» → «Передан в доставку» → «Доставлен».", "status": "PUBLISHED", "helpfulVotes": 6, "unhelpfulVotes": 0, "publishedDaysAgo": 20}, + {"slug": "uhod-za-shtorami", "category": "uhod", "title": "Как ухаживать за шторами", "summary": "Стирка и глажка по типам тканей", "content": "**Блэкаут** — деликатная стирка 30°, без отжима, глажка с изнанки на минимуме.\n\n**Лён** — стирка 40°; усадка до 3% заложена в расчёт.\n\n**Бархат** — только сухая чистка.\n\n**Тюль** — стирка 30° в мешке, сушить прямо на карнизе.", "status": "PUBLISHED", "helpfulVotes": 11, "unhelpfulVotes": 0, "publishedDaysAgo": 15}, + {"slug": "oplata-chastyami", "category": "zakaz", "title": "Оплата частями", "summary": "Черновик: условия 50/50 для заказов от 30 000 ₽", "content": "Для заказов от 30 000 ₽ возможна оплата 50% при заказе и 50% перед отправкой. Черновик — согласовать с бухгалтерией.", "status": "DRAFT", "helpfulVotes": 0, "unhelpfulVotes": 0}, + {"slug": "akciya-leto-2026", "category": "zakaz", "title": "Летняя акция 2026", "summary": "Скидка 10% на тюль до 31 августа", "content": "Акция завершена.", "status": "ARCHIVED", "helpfulVotes": 3, "unhelpfulVotes": 0, "publishedDaysAgo": 70} ] } } diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py index d532bb3..9ab0690 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/channels_ai.py @@ -1,154 +1,283 @@ -"""Каналы, интеграции, AI-агенты, база знаний (со вложениями), учёт LLM.""" +"""Агенты (карточки Channel+AIAgent), подключения, LLM-провайдер, знания, учёт LLM.""" from __future__ import annotations +from datetime import timedelta + from django.core.files.base import ContentFile +from hub_platform.ai.agent_card import ensure_channel_agent +from hub_platform.ai.knowledge_categories import create_category, ensure_uncategorized_category +from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge from hub_platform.ai.models import ( AIAgent, - AIAgentStatus, - Knowledge, KnowledgeAttachment, LlmInvocation, LlmInvocationStatus, ) from hub_platform.channels.models import Channel +from hub_platform.identity.audit import record_audit_event from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, now, rng from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus +from hub_platform.integrations.services import IntegrationInput, create_integration from hub_platform.tenancy.context import TenantContext from hub_platform.tenancy.storage_quota import finalize_storage, reserve_storage +from hub_platform.webchat.widgets import ensure_widget def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("channels_ai") - organization = refs.organization + current = now() - for item in data["integrations"]: - channel = refs.channels.get(item.get("channel")) - integration, _ = Integration.objects.get_or_create( - organization=organization, - provider=item["provider"], - name=item["name"], - defaults={ - "kind": item.get("kind", IntegrationKind.MESSENGER), - "channel": channel, - "status": item.get("status", IntegrationStatus.OK), - "config": item.get("config", {}), - }, + llm = _ensure_llm_provider(context, refs, data["llmProvider"], current) + for item in data["agents"]: + _ensure_agent(context, refs, item, llm, current) + for item in data["connections"]: + _ensure_connection(context, refs, item, current) + _ensure_categories(context, refs, data.get("knowledgeCategories", []), parent=None) + for item in data.get("knowledge", []): + _ensure_knowledge(context, refs, item, current) + for item in data["agents"]: + _attach_agent_knowledge(refs, item) + _generate_usage(refs, data.get("usage"), current) + + +def _ensure_llm_provider(context: TenantContext, refs: DemoRefs, item: dict, current) -> Integration: + integration = Integration.objects.filter( + organization=refs.organization, kind=IntegrationKind.LLM_PROVIDER, name=item["name"] + ).first() + if integration is None: + integration = create_integration( + context=context, + data=IntegrationInput( + provider=item["provider"], + name=item["name"], + secret=item.get("secret", ""), + config=item.get("config", {}), + ), ) - refs.integrations[item["key"]] = integration - - for item in data["channels"]: - _ensure_channel(refs, item) - - for item in data.get("agents", []): - _ensure_agent(refs, item) - - _ensure_knowledge(context, refs, data.get("knowledge", [])) - - for item in data.get("llmInvocations", []): - _ensure_llm_invocation(refs, item) + Integration.objects.filter(pk=integration.pk).update(status=item.get("status", IntegrationStatus.UNCHECKED)) + backdate(integration, current - timedelta(days=42)) + refs.integrations[item["key"]] = integration + return integration -def _ensure_channel(refs: DemoRefs, item: dict) -> None: - channel, _ = Channel.objects.get_or_create( - organization=refs.organization, +def _ensure_agent(context: TenantContext, refs: DemoRefs, item: dict, llm: Integration, current) -> None: + organization = refs.organization + channel, created = Channel.objects.get_or_create( + organization=organization, code=item["code"], defaults={ "name": item["name"], "group": refs.groups.get(item.get("group")), "product": refs.products.get(item.get("product")), - "provider_integration": refs.integrations.get(item.get("providerIntegration")), "is_active": item.get("isActive", True), **item.get("policy", {}), }, ) - refs.channels[item["code"]] = channel - - -def _ensure_agent(refs: DemoRefs, item: dict) -> None: - channel = refs.channels[item["channel"]] - agent, created = AIAgent.objects.get_or_create( - channel=channel, - defaults={ - "name": item["name"], - "status": item.get("status", AIAgentStatus.ACTIVE), - "model": item.get("model", ""), - "persona": item.get("persona", ""), - "tone": item.get("tone", ""), - "instructions": item.get("instructions", ""), - }, - ) + agent = ensure_channel_agent(channel) if created: - for knowledge_key in item.get("knowledge", []): - knowledge = refs.knowledge.get(knowledge_key) - if knowledge is not None: - agent.knowledge_items.add(knowledge) - - -def _ensure_knowledge(context: TenantContext, refs: DemoRefs, items: list[dict]) -> None: - organization = refs.organization - # Системная категория «Без категории» создаётся в foundation; хелпер - # идемпотентен, поэтому безопасно получить её здесь же. - from hub_platform.ai.knowledge_categories import ensure_uncategorized_category - - category = ensure_uncategorized_category(organization) - - for item in items: - knowledge, created = Knowledge.objects.get_or_create( + agent.status = item.get("aiStatus", agent.status) + agent.persona = item.get("persona", "") + agent.tone = item.get("tone", "") + agent.instructions = item.get("instructions", "") + agent.limits = item.get("limits", {}) + if agent.status in ("ACTIVE", "DISABLED"): + agent.provider_integration = llm + agent.model = (llm.config or {}).get("defaultModel", "") + agent.save() + when = current - timedelta(days=item.get("createdDaysAgo", 30)) + backdate(channel, when, "created_at", "updated_at") + backdate(agent, when, "created_at", "updated_at") + record_audit_event( organization=organization, - title=item["title"], - defaults={ - "category": category, - "description": item.get("description", ""), - "content": item.get("content", ""), - }, + actor=refs.users.get("anna"), + action="ai.agent_created", + object_type="Agent", + object_id=str(channel.id), + payload={"name": channel.name, "source": "demo"}, ) - refs.knowledge[item["key"]] = knowledge - if created: - for attachment_key in item.get("attachments", []): - _attach_knowledge_file(context, refs, knowledge, attachment_key) + refs.channels[item["key"]] = channel + refs.agents[item["key"]] = agent -def _attach_knowledge_file( - context: TenantContext, refs: DemoRefs, knowledge: Knowledge, filename: str -) -> None: +def _ensure_connection(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + organization = refs.organization + channel = refs.channels.get(item.get("agent")) + integration = Integration.objects.filter( + organization=organization, provider=item["provider"], name=item["name"] + ).first() + if integration is None: + integration = create_integration( + context=context, + data=IntegrationInput( + provider=item["provider"], + name=item["name"], + secret=item.get("secret", ""), + config=item.get("config", {}), + ), + ) + Integration.objects.filter(pk=integration.pk).update( + channel=channel, + status=item.get("status", IntegrationStatus.OK), + last_error=item.get("lastError", ""), + last_checked_at=current - timedelta(minutes=7), + ) + integration.refresh_from_db() + backdate(integration, current - timedelta(days=item.get("createdDaysAgo", 20))) + # Веб-виджет создаётся вместе с WEB-подключением, у которого есть канал. + widget = ensure_widget(integration) + if widget is not None: + widget.status = "PUBLISHED" + widget.allowed_origins = list(item.get("config", {}).get("allowed_origins", [])) + widget.save(update_fields=["status", "allowed_origins", "updated_at"]) + refs.widgets[item["key"]] = widget + record_audit_event( + organization=organization, + actor=refs.users.get("anna"), + action="channels.connection_bound", + object_type="Agent", + object_id=str(channel.id) if channel else "", + payload={"integrationId": integration.id, "source": "demo"}, + ) + refs.integrations[item["key"]] = integration + + +def _ensure_categories(context: TenantContext, refs: DemoRefs, items: list[dict], *, parent) -> None: + from hub_platform.ai.knowledge_models import KnowledgeCategory + + for index, item in enumerate(items, start=1): + category = KnowledgeCategory.objects.filter( + organization=refs.organization, name=item["name"], parent=parent + ).first() + if category is None: + category = create_category( + context=context, name=item["name"], parent=parent, sort_order=index + ) + refs.knowledge_categories[item["key"]] = category + _ensure_categories(context, refs, item.get("children", []), parent=category) + + +def _ensure_knowledge(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + from hub_platform.ai.models import Knowledge + + organization = refs.organization + knowledge = Knowledge.objects.filter(organization=organization, title=item["title"]).first() + if knowledge is None: + category = refs.knowledge_categories.get(item.get("category")) or ensure_uncategorized_category(organization) + knowledge = create_knowledge( + context=context, + data=KnowledgeInput( + title=item["title"], + description=item.get("description", ""), + content=item.get("content", ""), + is_enabled=item.get("isEnabled", True), + category_id=category.id, + ), + ) + when = current - timedelta(days=item.get("createdDaysAgo", 20)) + backdate(knowledge, when, "created_at", "updated_at") + for attachment in item.get("attachments", []): + _attach_knowledge_file(context, refs, knowledge, attachment) + refs.knowledge[item["key"]] = knowledge + + +def _attach_knowledge_file(context: TenantContext, refs: DemoRefs, knowledge, spec) -> None: + if isinstance(spec, str): + spec = {"file": spec} + filename = spec["file"] if KnowledgeAttachment.objects.filter(knowledge=knowledge, original_name=filename).exists(): return - data = manifest.media_text(filename).encode("utf-8") - reservation_key = f"demo-knowledge:{knowledge.id}:{filename}" - reserve_storage(context=context, expected_bytes=len(data), idempotency_key=reservation_key) + payload = manifest.media_bytes(filename) + text_source = spec.get("text", filename) + extracted_text = manifest.media_text(text_source) if text_source.rsplit(".", 1)[-1] in ("md", "txt") else "" suffix = filename.rsplit(".", 1)[-1].lower() - content_type = "text/markdown" if suffix == "md" else "application/octet-stream" + content_type = spec.get("contentType") or { + "md": "text/markdown", + "txt": "text/plain", + "pdf": "application/pdf", + }.get(suffix, "application/octet-stream") + reservation_key = f"demo-knowledge:{knowledge.id}:{filename}" + reserve_storage(context=context, expected_bytes=len(payload), idempotency_key=reservation_key) attachment = KnowledgeAttachment.objects.create( organization=refs.organization, knowledge=knowledge, original_name=filename, content_type=content_type, - size=len(data), - extracted_text=manifest.media_text(filename), + size=len(payload), + extracted_text=extracted_text, ) - attachment.file.save(filename, ContentFile(data), save=True) - finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(data)) + attachment.file.save(filename, ContentFile(payload), save=True) + finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(payload)) + if extracted_text: + from hub_platform.ai.indexing import reindex_knowledge + + reindex_knowledge(knowledge) -def _ensure_llm_invocation(refs: DemoRefs, item: dict) -> None: - channel = refs.channels[item["channel"]] - product = refs.products.get(item.get("product")) - prompt_tokens = item.get("promptTokens", 0) - completion_tokens = item.get("completionTokens", 0) - LlmInvocation.objects.get_or_create( - channel=channel, - purpose=item["purpose"], - operation=item.get("operation", "chat"), - model=item.get("model", ""), - defaults={ - "product": product, - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": prompt_tokens + completion_tokens, - "cost_micros": item.get("costMicros", 0), - "status": item.get("status", LlmInvocationStatus.SUCCESS), - }, - ) +def _attach_agent_knowledge(refs: DemoRefs, item: dict) -> None: + agent: AIAgent = refs.agents[item["key"]] + for key in item.get("knowledge", []): + knowledge = refs.knowledge.get(key) + if knowledge is not None: + agent.knowledge_items.add(knowledge) + # Статьи портала привязываются позже — после загрузчика поддержки (см. support.py). + refs.agent_article_links[item["key"]] = list(item.get("portalArticles", [])) + + +def _generate_usage(refs: DemoRefs, spec: dict | None, current) -> None: + """История LlmInvocation за N дней для раздела «Использование AI».""" + if not spec: + return + random = rng() + if LlmInvocation.objects.filter(channel__organization=refs.organization, purpose="agent_chat").exists(): + return + failures_left = spec.get("failuresTotal", 0) + days = spec.get("days", 30) + for day in range(days, -1, -1): + day_start = (current - timedelta(days=day)).replace(hour=9, minute=0, second=0, microsecond=0) + for agent_key, (low, high) in spec.get("chatPerDay", {}).items(): + channel = refs.channels.get(agent_key) + if channel is None: + continue + for _ in range(random.randint(low, high)): + prompt = random.randint(*spec["promptTokens"]) + completion = random.randint(*spec["completionTokens"]) + failed = failures_left > 0 and random.random() < 0.02 + if failed: + failures_left -= 1 + invocation = LlmInvocation.objects.create( + channel=channel, + product=channel.product, + purpose="agent_chat", + operation="chat", + model=spec["model"], + prompt_tokens=prompt, + completion_tokens=0 if failed else completion, + total_tokens=prompt + (0 if failed else completion), + cost_micros=0 if failed else int((prompt + completion) * spec["costMicrosPerToken"]), + latency_ms=random.randint(900, 4200), + status=LlmInvocationStatus.ERROR if failed else LlmInvocationStatus.SUCCESS, + error="Provider timeout after 30s" if failed else "", + ) + backdate(invocation, day_start + timedelta(minutes=random.randint(0, 660))) + low, high = spec.get("embeddingPerDay", [0, 0]) + channel = refs.channels.get("consultant") + for _ in range(random.randint(low, high)): + tokens = random.randint(200, 900) + invocation = LlmInvocation.objects.create( + channel=channel, + product=None, + purpose="knowledge_index", + operation="embedding", + model="text-embedding-3-small", + prompt_tokens=tokens, + completion_tokens=0, + total_tokens=tokens, + cost_micros=int(tokens * 0.02), + latency_ms=random.randint(200, 900), + status=LlmInvocationStatus.SUCCESS, + ) + backdate(invocation, day_start + timedelta(minutes=random.randint(0, 660))) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/common.py b/apps/backend/hub_platform/identity/demo_seed/loaders/common.py new file mode 100644 index 0000000..3f8d0a2 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/common.py @@ -0,0 +1,67 @@ +"""Общие помощники загрузчиков демо-сида. + +Время в манифестах относительное (``minutesAgo`` / ``hoursAgo`` / ``daysAgo``): +демо должно выглядеть живым в момент установки («ждёт 6 мин», «вчера»). +Поля ``auto_now_add`` нельзя задать при создании — их откатывают точечным +``UPDATE`` после сохранения. +""" + +from __future__ import annotations + +import random +from datetime import datetime, timedelta + +from django.db import models +from django.utils import timezone + +# Детерминированный генератор: повторная установка даёт те же «случайные» ряды. +DEMO_SEED = 20260905 + + +def rng() -> random.Random: + return random.Random(DEMO_SEED) + + +def moment(item: dict, now: datetime, prefix: str = "") -> datetime | None: + """Момент из ключей ``MinutesAgo`` / ``HoursAgo`` / ``DaysAgo``. + + Без префикса ключи — ``minutesAgo``, ``hoursAgo``, ``daysAgo``. Возвращает + ``None``, если ни одного ключа нет. + """ + + def key(name: str) -> str: + return f"{prefix}{name[0].upper()}{name[1:]}" if prefix else name + + delta = timedelta() + found = False + for name, unit in (("minutesAgo", "minutes"), ("hoursAgo", "hours"), ("daysAgo", "days")): + value = item.get(key(name)) + if value is not None: + delta += timedelta(**{unit: float(value)}) + found = True + return now - delta if found else None + + +def backdate(instance: models.Model, when: datetime | None, *fields: str) -> None: + """Переписывает временные поля записи (created_at и т. п.) без сигналов.""" + if when is None: + return + names = fields or ("created_at",) + values = {name: when for name in names if _has_field(instance, name)} + if not values: + return + type(instance)._base_manager.filter(pk=instance.pk).update(**values) + for name, value in values.items(): + setattr(instance, name, value) + + +def _has_field(instance: models.Model, name: str) -> bool: + try: + instance._meta.get_field(name) + except Exception: # noqa: BLE001 + return False + return True + + +def now() -> datetime: + return timezone.now() diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py b/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py index 03f932c..03939e8 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/conversations.py @@ -1,54 +1,95 @@ -"""Диалоги: контакты, идентичности подключений, диалоги, сообщения, прочтения.""" +"""Диалоги: контакты, идентичности, метки, шаблоны, диалоги во всех состояниях, +сообщения (текст, системные, контакт, голосовые), прочтения, веб-гость.""" from __future__ import annotations +import logging from datetime import timedelta -from django.utils import timezone +from django.core.files.base import ContentFile from hub_platform.conversations.models import ( ConnectionIdentity, Contact, - ControlMode, + ConversationLabel, Conversation, ConversationRead, - ExpectedResponder, - LifecycleState, Message, MessageAuthor, + MessageKind, + ReplyTemplate, + TranscriptStatus, ) from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.tenancy.context import TenantContext +from hub_platform.webchat.services import issue_session + +logger = logging.getLogger(__name__) + +AVATAR_URL = "/api/v1/demo-media/avatars/{name}" def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("conversations") organization = refs.organization - now = timezone.now() + current = now() for item in data["contacts"]: - contact, _ = Contact.objects.update_or_create( + contact, created = Contact.objects.get_or_create( organization=organization, name=item["name"], - defaults={"phone": item.get("phone", ""), "avatar_url": item.get("avatarUrl", "")}, + defaults={ + "phone": item.get("phone", ""), + "avatar_url": AVATAR_URL.format(name=item["avatar"]) if item.get("avatar") else "", + }, ) + if created: + backdate(contact, moment(item, current, "created")) refs.contacts[item["key"]] = contact for item in data["connectionIdentities"]: _ensure_identity(refs, item) - for item in data["conversations"]: - _ensure_conversation(refs, item, now) + for item in data.get("labels", []): + label, _ = ConversationLabel.objects.get_or_create( + organization=organization, name=item["name"], defaults={"color": item.get("color", "")} + ) + refs.labels[item["key"]] = label + + for item in data.get("replyTemplates", []): + ReplyTemplate.objects.get_or_create( + organization=organization, title=item["title"], defaults={"text": item["text"]} + ) + + # Диалоги с previousConversation ссылаются на более ранние — создаём в два прохода. + pending = list(data["conversations"]) + created_keys: set[str] = set() + while pending: + progressed = False + for item in list(pending): + previous_key = item.get("previousConversation") + if previous_key and previous_key not in created_keys: + continue + _ensure_conversation(context, refs, item, current) + created_keys.add(item["key"]) + pending.remove(item) + progressed = True + if not progressed: + raise manifest.ManifestError( + "conversations: previousConversation forms a cycle or points to an unknown key" + ) def _ensure_identity(refs: DemoRefs, item: dict) -> None: connection = refs.integrations[item["connection"]] contact = refs.contacts[item["contact"]] - identity, _ = ConnectionIdentity.objects.update_or_create( + identity, _ = ConnectionIdentity.objects.get_or_create( connection=connection, external_user_id=item["externalUserId"], defaults={ + "organization": refs.organization, "contact": contact, "display_name": item.get("displayName", ""), "username": item.get("username", ""), @@ -57,60 +98,117 @@ def _ensure_identity(refs: DemoRefs, item: dict) -> None: refs.identities[item["key"]] = identity -def _ensure_conversation(refs: DemoRefs, item: dict, now) -> None: - organization = refs.organization - channel = refs.channels[item["channel"]] - contact = refs.contacts.get(item.get("contact")) - connection = refs.integrations.get(item.get("connection")) - snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot")) - is_human = item.get("controlMode") == ControlMode.HUMAN - operator = refs.users.get(item.get("assignedOperator")) if is_human else None +def _web_guest(context: TenantContext, refs: DemoRefs, item: dict) -> tuple[Contact, ConnectionIdentity, str]: + """Анонимный гость виджета: настоящая веб-сессия через сервис webchat.""" + widget = refs.widgets[item["connection"]] + session = issue_session(context=context, widget=widget) + if session is None: + raise manifest.ManifestError("web widget has no channel — cannot issue guest session") + identity = ConnectionIdentity.objects.get( + connection=refs.integrations[item["connection"]], external_user_id=session["sessionId"] + ) + return identity.contact, identity, session["sessionId"] - conversation, _ = Conversation.objects.get_or_create( + +def _ensure_conversation(context: TenantContext, refs: DemoRefs, item: dict, current) -> None: + organization = refs.organization + channel = refs.channels[item["agent"]] + connection = refs.integrations[item["connection"]] + if item.get("webGuest"): + contact, identity, external_chat_id = _web_guest(context, refs, item) + else: + contact = refs.contacts.get(item.get("contact")) + identity = refs.identities.get(item.get("identity")) + external_chat_id = item["externalChatId"] + + existing = Conversation.objects.filter( + organization=organization, channel=channel, external_chat_id=external_chat_id + ).order_by("-id") + # Один и тот же чат может иметь несколько диалогов (история контакта): + # ключ уникальности — чат + момент создания. + created_at = moment(item, current, "created") or current + conversation = existing.filter(created_at=created_at).first() + if conversation is not None: + refs.conversations[item["key"]] = conversation + return + + snapshot = refs.identity_snapshots.get(item.get("supportIdentitySnapshot")) + conversation = Conversation.objects.create( organization=organization, channel=channel, - external_chat_id=item["externalChatId"], - defaults={ - "connection": connection, - "contact": contact, - "support_identity_snapshot": snapshot, - "lifecycle": item.get("lifecycle", LifecycleState.OPEN), - "control_mode": item.get("controlMode", ControlMode.AI), - "expected_responder": item.get("expectedResponder", ExpectedResponder.AI), - "assigned_operator": operator, - "transport_meta": item.get("transportMeta", {}), - }, + connection=connection, + # Ровно одна личность: снимок из личного кабинета либо контакт. + contact=None if snapshot is not None else contact, + support_identity_snapshot=snapshot, + external_chat_id=external_chat_id, + transport_meta=item.get("transportMeta", {}), + lifecycle=item.get("lifecycle", "OPEN"), + control_mode=item.get("controlMode", "AI"), + expected_responder=item.get("expectedResponder", "AI"), + group=refs.groups.get(item.get("group")), + assigned_operator=refs.users.get(item.get("assignedOperator")), + priority=item.get("priority", "NONE"), + note=item.get("note", ""), + previous_conversation=refs.conversations.get(item.get("previousConversation")), ) - Conversation.objects.filter(pk=conversation.pk).update( - last_activity_at=now - timedelta(minutes=item.get("lastActivityMinutesAgo", 0)) - ) - conversation.refresh_from_db() + for label_key in item.get("labels", []): + label = refs.labels.get(label_key) + if label is not None: + conversation.labels.add(label) + archived_at = moment(item, current, "archived") + if archived_at is not None: + Conversation.objects.filter(pk=conversation.pk).update(archived_at=archived_at) + last_activity = moment(item, current, "lastActivity") or created_at + backdate(conversation, created_at, "created_at") + backdate(conversation, last_activity, "last_activity_at") refs.conversations[item["key"]] = conversation - last_message_id = 0 - for message in item.get("messages", []): - author_type = message["author"] - msg, _ = Message.objects.get_or_create( - conversation=conversation, - external_id=message["externalId"], - defaults={ - "author_type": author_type, - "author_user": refs.users.get(message.get("authorUser")) - if author_type == MessageAuthor.OPERATOR - else None, - "kind": message.get("kind", ""), - "text": message.get("text", ""), - "content_html": message.get("contentHtml", ""), - }, - ) - last_message_id = max(last_message_id, msg.id) + last_message: Message | None = None + for index, spec in enumerate(item.get("messages", []), start=1): + message = _create_message(context, refs, conversation, spec, index, current) + if message is not None: + last_message = message - reader_key = item.get("readBy") - if reader_key and last_message_id: - user = refs.users.get(reader_key) - if user is not None: - ConversationRead.objects.update_or_create( - conversation=conversation, - user=user, - defaults={"last_read_message_id": last_message_id}, - ) + if last_message is not None: + for reader_key in item.get("readBy", []): + user = refs.users.get(reader_key) + if user is not None: + ConversationRead.objects.update_or_create( + conversation=conversation, + user=user, + defaults={"last_read_message_id": last_message.id}, + ) + + +def _create_message(context: TenantContext, refs: DemoRefs, conversation: Conversation, spec: dict, index: int, current) -> Message | None: + author_type = spec["author"] + kind = spec.get("kind", MessageKind.TEXT) + voice = spec.get("voice") + if kind == MessageKind.VOICE and voice is not None: + filename = f"voice/{voice['file']}" + if not manifest.media_exists(filename): + logger.warning("Demo voice file %s is missing — message skipped", filename) + return None + message = Message.objects.create( + conversation=conversation, + author_type=author_type, + author_user=refs.users.get(spec.get("authorUser")) if author_type == MessageAuthor.OPERATOR else None, + kind=kind, + text=spec.get("text", ""), + content_html=spec.get("contentHtml", ""), + external_id=f"demo-{conversation.id}-{index}", + ) + if kind == MessageKind.VOICE and voice is not None: + payload = manifest.media_bytes(f"voice/{voice['file']}") + content_type = voice.get("contentType", "audio/ogg") + suffix = voice["file"].rsplit(".", 1)[-1] + message.audio_content_type = content_type + message.duration_seconds = int(voice.get("durationSeconds", 0)) + message.transcript = voice.get("transcript", "") + message.transcript_status = voice.get("transcriptStatus", TranscriptStatus.NONE) + message.audio.save(f"voice.{suffix}", ContentFile(payload), save=False) + message.save( + update_fields=["audio", "audio_content_type", "duration_seconds", "transcript", "transcript_status"] + ) + backdate(message, moment(spec, current) or current - timedelta(minutes=1)) + return message diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py index 731f54b..c4c7a21 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/foundation.py @@ -1,89 +1,135 @@ -"""Базовый слой: организация, группы, пользователи, участия, аудит. +"""Базовый слой: группы, демо-сотрудники, участия, приглашения. -Использует существующие идемпотентные хелперы (``ensure_*``). +Организация и её владелец не создаются — демо ставится в организацию +установщика (``context.organization``). Пароль всех демо-учёток один +(``demoPassword`` манифеста): установщик входит под любым из них. """ from __future__ import annotations -from hub_platform.ai.knowledge_categories import ensure_uncategorized_category +from datetime import timedelta + from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.auth.totp_utils import _generate_totp_secret from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.identity.group_models import EmployeeGroup, EmployeeGroupMember -from hub_platform.identity.models import ( - HumanUser, - Organization, - OrganizationMembership, -) +from hub_platform.identity.invitation_service import issue_invitation +from hub_platform.identity.models import HumanUser, OrganizationMembership from hub_platform.tenancy.context import TenantContext def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("organization") - org_data = data["organization"] - - organization, created = Organization.objects.get_or_create( - slug=org_data["slug"], - defaults={ - "name": org_data["name"], - "timezone": org_data["timezone"], - "currency": org_data["currency"], - }, - ) - refs.organization = organization + organization = refs.organization + current = now() for item in data.get("groups", []): - group, _ = EmployeeGroup.objects.get_or_create( - organization=organization, name=item["name"] - ) + group, _ = EmployeeGroup.objects.get_or_create(organization=organization, name=item["name"]) refs.groups[item["key"]] = group - ensure_uncategorized_category(organization) - - _ensure_users_and_memberships(context, refs, data) - - if created: - record_audit_event( - organization=organization, - actor=refs.users.get("owner"), - action="demo.organization_created", - object_type="Organization", - object_id=str(organization.public_id), - payload={"source": "demo-seed"}, - ) - - -def _ensure_users_and_memberships(context: TenantContext, refs: DemoRefs, data: dict) -> None: - organization = refs.organization - + password = data["demoPassword"] for item in data["accounts"]: - email = HumanUser.objects.normalize_email(item["email"]) - user, user_created = HumanUser.objects.get_or_create( - email=email, - defaults={ - "full_name": item["fullName"], - "is_staff": item.get("isStaff", False), - "is_superuser": item.get("isSuperuser", False), - }, - ) - if user_created: - user.set_password(item["password"]) - user.save(update_fields=["password"]) - refs.users[item["key"]] = user + _ensure_account(refs, item, password, current) - membership, _ = OrganizationMembership.objects.get_or_create( - user=user, + for item in data.get("invitations", []): + created_by = refs.memberships.get(item.get("createdBy")) + expires_in = item.get("expiresInDays", 7) + issued = issue_invitation( organization=organization, - defaults={ - "role": item["role"], - "position_title": item.get("positionTitle", ""), - "phone": item.get("phone", ""), - }, + email=item["email"], + role=item["role"], + expires_at=current + timedelta(days=max(expires_in, 1)), + created_by=created_by, ) - refs.memberships[item["key"]] = membership + if expires_in < 0: + # Просроченное приглашение: выдано неделю назад, срок вышел. Валидация + # модели не даёт создать его сразу просроченным — откатываем даты. + backdate( + issued.invitation, + current - timedelta(days=7), + "created_at", + ) + type(issued.invitation)._base_manager.filter(pk=issued.invitation.pk).update( + expires_at=current + timedelta(days=expires_in) + ) - group = refs.groups.get(item.get("group", "")) + record_audit_event( + organization=organization, + actor=refs.users.get("anna"), + action="demo.installed_accounts", + object_type="Organization", + object_id=str(organization.public_id), + payload={"accounts": len(refs.users)}, + ) + + +def _ensure_account(refs: DemoRefs, item: dict, password: str, current) -> None: + organization = refs.organization + email = HumanUser.objects.normalize_email(item["email"]) + user, created = HumanUser.objects.get_or_create( + email=email, + defaults={ + "full_name": item["fullName"], + "ui_theme": item.get("uiTheme", "SYSTEM"), + "ui_accent": item.get("uiAccent", ""), + }, + ) + if created: + user.set_password(password) + if item.get("totpEnabled"): + user.totp_enabled = True + user.totp_secret = _generate_totp_secret() + user.save() + refs.users[item["key"]] = user + + membership, membership_created = OrganizationMembership.objects.get_or_create( + user=user, + organization=organization, + defaults={ + "role": item["role"], + "position_title": item.get("positionTitle", ""), + "phone": item.get("phone", ""), + "blocked_at": current - timedelta(days=21) if item.get("blocked") else None, + }, + ) + if membership_created: + backdate(membership, current - timedelta(days=item.get("createdDaysAgo", 45))) + refs.memberships[item["key"]] = membership + + for group_key in item.get("groups", []): + group = refs.groups.get(group_key) if group is not None: EmployeeGroupMember.objects.get_or_create( organization=organization, group=group, employee=membership ) + + if membership_created: + record_audit_event( + organization=organization, + actor=refs.users.get("anna") or user, + action="identity.employee_created", + object_type="OrganizationMembership", + object_id=str(membership.id), + payload={"email": email, "role": item["role"], "source": "demo"}, + ) + if item.get("blocked"): + record_audit_event( + organization=organization, + actor=refs.users.get("anna") or user, + action="identity.employee_blocked", + object_type="OrganizationMembership", + object_id=str(membership.id), + payload={"source": "demo"}, + ) + # Демо-сотрудники «входили в систему» — история для аудита и «последний вход». + login_at = moment(item, current, "lastLogin") or current - timedelta(hours=3) + if not item.get("blocked"): + HumanUser.objects.filter(pk=user.pk).update(last_login=login_at) + record_audit_event( + organization=organization, + actor=user, + action="identity.login_succeeded", + payload={"source": "demo"}, + ) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py index ff838f5..1bb0bba 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/operations.py @@ -1,24 +1,29 @@ -"""Операционные данные: звонки, уведомления, период учёта использования.""" +"""Операционные данные: звонки (с метриками, участниками и приглашением), +уведомления всех типов, привязки уведомителя к мессенджеру.""" from __future__ import annotations +import hashlib from datetime import timedelta -from django.utils import timezone - from hub_platform.calls.models import ( CallConnectionType, CallEndedBy, + CallInvite, CallMetric, CallParticipant, CallSession, CallStatus, + InviteDeliveryStatus, ParticipantConnectionState, ParticipantSide, ) from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, moment, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.notifications.models import ( + MessengerBinding, + MessengerBindingCode, Notification, NotificationAudience, NotificationLevel, @@ -26,105 +31,151 @@ from hub_platform.notifications.models import ( ) from hub_platform.tenancy.context import TenantContext +FINISHED = { + CallStatus.ENDED, + CallStatus.DECLINED, + CallStatus.CANCELLED, + CallStatus.MISSED, + CallStatus.FAILED, + CallStatus.EXPIRED, +} + def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("operations") - _ensure_calls(refs, data.get("calls", [])) - _ensure_notifications(refs, data.get("notifications", [])) - - -def _ensure_calls(refs: DemoRefs, items: list[dict]) -> None: - now = timezone.now() - for item in items: - conversation = refs.conversations[item["conversation"]] - initiator = refs.users[item["initiatedBy"]] - if ( - CallSession.objects.filter(conversation=conversation) - .exclude(status__in=("ENDED", "DECLINED", "CANCELLED", "MISSED", "FAILED", "EXPIRED")) - .exists() - ): - continue # незавершённый звонок уже есть — не трогаем - - started = now - timedelta(minutes=item.get("minutesAgo", 10)) - session, created = CallSession.objects.get_or_create( - conversation=conversation, + current = now() + for item in data.get("calls", []): + _ensure_call(refs, item, current) + for item in data.get("notifications", []): + _ensure_notification(refs, item, current) + for item in data.get("messengerBindings", []): + MessengerBinding.objects.get_or_create( + user=refs.users[item["user"]], + integration=refs.integrations[item["connection"]], defaults={ "organization": refs.organization, - "initiated_by": initiator, - "status": item.get("status", CallStatus.ENDED), - "delivery_connection": refs.integrations.get(item.get("deliveryConnection")), + "external_chat_id": item["externalChatId"], + "push_types": item.get("pushTypes", []), + }, + ) + for item in data.get("messengerBindingCodes", []): + MessengerBindingCode.objects.get_or_create( + code=item["code"], + defaults={ + "organization": refs.organization, + "user": refs.users[item["user"]], + "integration": refs.integrations[item["connection"]], + "expires_at": current + timedelta(minutes=item.get("expiresInMinutes", 15)), }, ) - if not created: - continue - if session.status == CallStatus.ENDED: - accepted = started + timedelta(seconds=8) - session.accepted_at = accepted - session.connected_at = accepted - session.ended_at = started + timedelta(seconds=item.get("durationSeconds", 120)) - session.ended_by = item.get("endedBy", CallEndedBy.STAFF) - call_fields = ["accepted_at", "connected_at", "ended_at", "ended_by", "updated_at"] - session.save(update_fields=call_fields) - _ensure_call_metric(session, item) - - contact_identity = refs.identities.get(item.get("contactIdentity")) - if contact_identity is not None: - CallParticipant.objects.get_or_create( - call_session=session, - side=ParticipantSide.CUSTOMER, - defaults={ - "connection_identity": contact_identity, - "joined_at": session.connected_at, - "left_at": session.ended_at, - "last_connection_state": ParticipantConnectionState.DISCONNECTED, - }, - ) - CallParticipant.objects.get_or_create( - call_session=session, - side=ParticipantSide.STAFF, - defaults={ - "user": initiator, - "joined_at": session.connected_at, - "left_at": session.ended_at, - "last_connection_state": ParticipantConnectionState.DISCONNECTED, - }, - ) -def _ensure_call_metric(session: CallSession, item: dict) -> None: +def _ensure_call(refs: DemoRefs, item: dict, current) -> None: + conversation = refs.conversations[item["conversation"]] + initiator = refs.users[item["initiatedBy"]] + if CallSession.objects.filter(conversation=conversation, initiated_by=initiator, kind=item.get("kind", "AUDIO")).exists(): + return + started = moment(item, current) or current - timedelta(minutes=10) + status = item.get("status", CallStatus.ENDED) + session = CallSession.objects.create( + organization=refs.organization, + conversation=conversation, + initiated_by=initiator, + status=status, + kind=item.get("kind", "AUDIO"), + delivery_connection=refs.integrations.get(item.get("deliveryConnection")), + ) + backdate(session, started, "requested_at") + if status == CallStatus.ENDED: + accepted = started + timedelta(seconds=8) + session.accepted_at = accepted + session.connected_at = accepted + session.ended_at = started + timedelta(seconds=item.get("durationSeconds", 120)) + session.ended_by = item.get("endedBy", CallEndedBy.STAFF) + session.save(update_fields=["accepted_at", "connected_at", "ended_at", "ended_by", "updated_at"]) + _ensure_metric(session, item) + elif status in (CallStatus.DECLINED, CallStatus.MISSED, CallStatus.EXPIRED, CallStatus.CANCELLED): + session.ended_at = started + timedelta(seconds=45) + session.ended_by = item.get("endedBy", "") + session.save(update_fields=["ended_at", "ended_by", "updated_at"]) + + identity = refs.identities.get(item.get("contactIdentity")) + if identity is not None: + CallParticipant.objects.create( + organization=refs.organization, + call_session=session, + side=ParticipantSide.CUSTOMER, + connection_identity=identity, + joined_at=session.connected_at, + left_at=session.ended_at, + last_connection_state=ParticipantConnectionState.DISCONNECTED, + ) + CallParticipant.objects.create( + organization=refs.organization, + call_session=session, + side=ParticipantSide.STAFF, + user=initiator, + joined_at=session.connected_at, + left_at=session.ended_at, + last_connection_state=ParticipantConnectionState.DISCONNECTED, + ) + invite = item.get("invite") + if invite is not None and identity is not None: + token_hash = hashlib.sha256(f"demo-invite-{session.id}".encode()).hexdigest() + call_invite = CallInvite.objects.create( + organization=refs.organization, + call_session=session, + connection_identity=identity, + token_hash=token_hash, + expires_at=started + timedelta(minutes=invite.get("expiredMinutesAfter", 2)), + opened_at=None, + responded_at=None, + delivery_status=InviteDeliveryStatus.SENT + if invite.get("deliveryStatus", "SENT") in ("SENT", "DELIVERED") + else invite.get("deliveryStatus"), + ) + backdate(call_invite, started) + refs.calls[item["key"]] = session + + +def _ensure_metric(session: CallSession, item: dict) -> None: metrics = item.get("metrics") if not metrics: return - CallMetric.objects.update_or_create( + CallMetric.objects.create( + organization=session.organization, call_session=session, side=ParticipantSide.STAFF, - defaults={ - "connection_type": metrics.get("connectionType", CallConnectionType.DIRECT), - "local_candidate_type": metrics.get("localCandidateType", "host"), - "remote_candidate_type": metrics.get("remoteCandidateType", "srflx"), - "round_trip_ms": metrics.get("roundTripMs"), - }, + connection_type=metrics.get("connectionType", CallConnectionType.DIRECT), + local_candidate_type=metrics.get("localCandidateType", "host"), + remote_candidate_type=metrics.get("remoteCandidateType", "srflx"), + round_trip_ms=metrics.get("roundTripMs"), ) -def _ensure_notifications(refs: DemoRefs, items: list[dict]) -> None: - for item in items: - dedup_key = f"demo:{item['type']}:{item.get('sourceId', '')}" - notification, created = Notification.objects.get_or_create( - organization=refs.organization, - dedup_key=dedup_key, - defaults={ - "type": item["type"], - "level": item.get("level", NotificationLevel.INFO), - "title": item["title"], - "body": item.get("body", ""), - "target_route": item.get("targetRoute", ""), - "target_id": item.get("targetId", ""), - "audience": item.get("audience", NotificationAudience.ALL), - "recipient_user": refs.users.get(item.get("recipientUser")), - }, - ) - if created and item.get("readBy"): - user = refs.users.get(item["readBy"]) - if user is not None: - NotificationRead.objects.get_or_create(notification=notification, user=user) +def _ensure_notification(refs: DemoRefs, item: dict, current) -> None: + conversation = refs.conversations.get(item.get("targetConversation")) + dedup_key = f"demo:{item['type']}:{item.get('targetConversation') or item['title']}" + notification, created = Notification.objects.get_or_create( + organization=refs.organization, + dedup_key=dedup_key, + defaults={ + "type": item["type"], + "level": item.get("level", NotificationLevel.INFO), + "title": item["title"], + "body": item.get("body", ""), + "target_route": item.get("targetRoute", ""), + "target_id": str(conversation.id) if conversation is not None else item.get("targetId", ""), + "audience": item.get("audience", NotificationAudience.ALL), + "recipient_user": refs.users.get(item.get("recipientUser")), + }, + ) + if not created: + return + backdate(notification, moment(item, current) or current) + for reader_key in item.get("readBy", []): + user = refs.users.get(reader_key) + if user is not None: + NotificationRead.objects.get_or_create( + notification=notification, user=user, defaults={"organization": refs.organization} + ) diff --git a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py index aca219e..c51aa41 100644 --- a/apps/backend/hub_platform/identity/demo_seed/loaders/support.py +++ b/apps/backend/hub_platform/identity/demo_seed/loaders/support.py @@ -1,12 +1,12 @@ -"""Поддержка: контракты, снимки идентичности, портал, статьи и ревизии.""" +"""Поддержка: контракты, снимки личности, портал помощи, статьи (опубликованные, +черновик, архив, вторая ревизия), оценки читателей, статьи у агентов.""" from __future__ import annotations from datetime import timedelta -from django.utils import timezone - from hub_platform.identity.demo_seed import manifest +from hub_platform.identity.demo_seed.loaders.common import backdate, now from hub_platform.identity.demo_seed.refs import DemoRefs from hub_platform.support.models import ( ContractStatus, @@ -14,16 +14,19 @@ from hub_platform.support.models import ( SupportIdentitySnapshot, ) from hub_platform.support_portals.content_services import ( + add_revision, + archive_article, create_article, create_category, - ensure_portal_editable, publish_revision, record_feedback, ) -from hub_platform.support_portals.models import ( - PortalCategory, - SupportPortal, - SupportPortalProduct, +from hub_platform.support_portals.models import PortalCategory, SupportPortal +from hub_platform.support_portals.portal_services import ( + PortalInput, + create_portal, + replace_product_links, + set_portal_status, ) from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus from hub_platform.tenancy.context import TenantContext @@ -31,9 +34,11 @@ from hub_platform.tenancy.context import TenantContext def load(context: TenantContext, refs: DemoRefs) -> None: data = manifest.load("support") + current = now() _ensure_contracts(refs, data.get("contracts", [])) - _ensure_snapshots(refs, data.get("identitySnapshots", [])) - _ensure_portal(context, refs, data.get("portal")) + _ensure_snapshots(refs, data.get("identitySnapshots", []), current) + _ensure_portal(context, refs, data.get("portal"), current) + _link_agent_articles(refs) def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None: @@ -47,11 +52,13 @@ def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None: "status": item.get("status", ContractStatus.ACTIVE), }, ) + support_channel = refs.channels.get("support") + if support_channel is not None: + contract.allowed_channels.add(support_channel) refs.support_contracts[item["key"]] = contract -def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None: - now = timezone.now() +def _ensure_snapshots(refs: DemoRefs, items: list[dict], current) -> None: for item in items: contract = refs.support_contracts[item["contract"]] snapshot, _ = SupportIdentitySnapshot.objects.get_or_create( @@ -68,41 +75,43 @@ def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None: "operator_context_json": item.get("operatorContext", {}), "ai_context_json": item.get("aiContext", {}), "search_text": item.get("searchText", ""), - "token_issued_at": now - timedelta(days=item.get("issuedDaysAgo", 1)), - "token_expires_at": now + timedelta(days=item.get("expiresInDays", 30)), + "token_issued_at": current - timedelta(days=item.get("issuedDaysAgo", 1)), + "token_expires_at": current + timedelta(days=item.get("expiresInDays", 30)), }, ) refs.identity_snapshots[item["key"]] = snapshot -def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None) -> None: +def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None, current) -> None: if not portal_data: return - organization = refs.organization - - portal, portal_created = SupportPortal.objects.get_or_create( - slug=portal_data["slug"], - defaults={ - "organization": organization, - "name": portal_data["name"], - "default_locale": portal_data.get("locale", "ru"), - "status": portal_data.get("status", PortalStatus.PUBLISHED), - "widget_channel": refs.channels.get(portal_data.get("widgetChannel")), - }, - ) - if portal_created and portal.status == PortalStatus.PUBLISHED: - portal.published_at = timezone.now() - portal.save(update_fields=["published_at"]) - ensure_portal_editable(portal) - - for item in portal_data.get("products", []): - product = refs.products[item] - support_channel = refs.channels.get(f"{item}-support") - SupportPortalProduct.objects.get_or_create( - portal=portal, - product=product, - defaults={"support_channel": support_channel}, + portal = SupportPortal.objects.filter(organization=refs.organization, slug=portal_data["slug"]).first() + if portal is None: + widget = refs.widgets.get(portal_data.get("widgetConnection")) + portal = create_portal( + context=context, + data=PortalInput( + slug=portal_data["slug"], + name=portal_data["name"], + default_locale=portal_data.get("locale", "ru"), + widget_id=widget.id if widget is not None else None, + ), ) + backdate(portal, current - timedelta(days=28), "created_at") + # Виджет портала — анонимный (чат на странице помощи); виджет продукта — + # авторизованный (личный кабинет), через него идут обращения с личностью. + account_widget = refs.widgets.get(portal_data.get("accountWidgetConnection")) + links = [] + for product_code in portal_data.get("products", []): + product = refs.products[product_code] + links.append( + { + "productId": product.id, + "supportWidgetId": account_widget.id if account_widget is not None else None, + } + ) + if links: + replace_product_links(context=context, portal=portal, links=links) categories: dict[str, PortalCategory] = {} for item in portal_data.get("categories", []): @@ -121,30 +130,67 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N categories[item["slug"]] = category for item in portal_data.get("articles", []): - _ensure_article(context, portal, categories, item) + _ensure_article(context, refs, portal, categories, item, current) + + target_status = portal_data.get("status", PortalStatus.PUBLISHED) + if portal.status != target_status: + set_portal_status(context=context, portal=portal, status=target_status) + refs.portal = portal -def _ensure_article( - context: TenantContext, - portal: SupportPortal, - categories: dict[str, PortalCategory], - item: dict, -) -> None: - existing = portal.articles.filter(slug=item["slug"]).first() - if existing is not None and existing.revisions.exists(): - return # статья уже создана — идемпотентность - data = { - "slug": item["slug"], - "categoryId": categories[item["category"]].id, - "locale": item.get("locale", portal.default_locale), - "title": item["title"], - "summary": item.get("summary", ""), - "content": item.get("content", ""), - } - article = create_article(context=context, portal=portal, data=data) - if item.get("status", ArticleStatus.PUBLISHED) == ArticleStatus.PUBLISHED: - latest_revision = article.revisions.order_by("-revision").first() - if latest_revision is not None: - publish_revision(article=article, revision_id=latest_revision.id) - for _ in range(item.get("helpfulVotes", 0)): - record_feedback(article, helpful=True) +def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPortal, categories, item: dict, current) -> None: + article = portal.articles.filter(slug=item["slug"]).first() + if article is None: + article = create_article( + context=context, + portal=portal, + data={ + "slug": item["slug"], + "categoryId": categories[item["category"]].id, + "locale": item.get("locale", portal.default_locale), + "title": item["title"], + "summary": item.get("summary", ""), + "content": item.get("content", ""), + }, + ) + published_at = current - timedelta(days=item.get("publishedDaysAgo", 7)) + backdate(article, published_at - timedelta(days=1), "created_at") + first = article.revisions.order_by("revision").first() + backdate(first, published_at - timedelta(days=1), "created_at") + status = item.get("status", ArticleStatus.PUBLISHED) + if status in (ArticleStatus.PUBLISHED, ArticleStatus.ARCHIVED): + publish_revision(article=article, revision_id=first.id) + first.refresh_from_db() + backdate(first, published_at, "published_at") + second = item.get("secondRevision") + if second and status == ArticleStatus.PUBLISHED: + revision = add_revision( + context=context, + article=article, + data={ + "title": item["title"], + "summary": second.get("summary", item.get("summary", "")), + "content": item.get("content", "") + second.get("contentAppend", ""), + }, + ) + publish_revision(article=article, revision_id=revision.id) + when = current - timedelta(days=second.get("daysAgo", 3)) + backdate(revision, when, "created_at", "published_at") + for _ in range(item.get("helpfulVotes", 0)): + record_feedback(article, helpful=True) + for _ in range(item.get("unhelpfulVotes", 0)): + record_feedback(article, helpful=False) + if status == ArticleStatus.ARCHIVED: + archive_article(article) + refs.portal_articles[item["slug"]] = article + + +def _link_agent_articles(refs: DemoRefs) -> None: + for agent_key, slugs in refs.agent_article_links.items(): + agent = refs.agents.get(agent_key) + if agent is None: + continue + for slug in slugs: + article = refs.portal_articles.get(slug) + if article is not None and article.status == ArticleStatus.PUBLISHED: + agent.portal_articles.add(article) diff --git a/apps/backend/hub_platform/identity/demo_seed/manifest.py b/apps/backend/hub_platform/identity/demo_seed/manifest.py index 32b404b..9f56124 100644 --- a/apps/backend/hub_platform/identity/demo_seed/manifest.py +++ b/apps/backend/hub_platform/identity/demo_seed/manifest.py @@ -63,21 +63,26 @@ def media_path(filename: str) -> Path: def media_text(filename: str) -> str: - """Текстовое содержимое вложения (для SVG/Markdown).""" + """Текстовое содержимое вложения (для Markdown/текста).""" return media_path(filename).read_text(encoding="utf-8") +def media_bytes(filename: str) -> bytes: + """Бинарное содержимое вложения (PDF, аудио, изображения).""" + return media_path(filename).read_bytes() + + +def media_exists(filename: str) -> bool: + return (MEDIA_DIR / filename).is_file() + + def _validate_organization(payload: dict) -> None: if not isinstance(payload, dict): raise ManifestError("organization manifest must be a JSON object") if payload.get("schemaVersion") != SCHEMA_VERSION: raise ManifestError(f"organization manifest schemaVersion must be {SCHEMA_VERSION}") - org = payload.get("organization") - if not isinstance(org, dict): - raise ManifestError("organization block is required") - for key in ("slug", "name", "timezone", "currency"): - if not org.get(key): - raise ManifestError(f"organization.{key} is required") + if not payload.get("demoPassword") or not isinstance(payload.get("accounts"), list): + raise ManifestError("organization manifest requires demoPassword and accounts") def _validate_section(payload: dict | list, name: str) -> None: diff --git a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py b/apps/backend/hub_platform/identity/demo_seed/orchestrator.py index 14f3b19..f5274f6 100644 --- a/apps/backend/hub_platform/identity/demo_seed/orchestrator.py +++ b/apps/backend/hub_platform/identity/demo_seed/orchestrator.py @@ -2,7 +2,7 @@ Создаёт полный, взаимосвязанный набор выдуманных данных одной организации, будто система уже работает. Запускается строго в порядке FK-зависимостей: -foundation → catalog → channels/ai → conversations → support → operations. +foundation → catalog → channels/ai → support → conversations → operations. Идемпотентен на каждом шаге. """ @@ -32,18 +32,26 @@ from hub_platform.tenancy.context import TenantContext __all__ = ["DemoRefs", "run_demo_seed"] -def run_demo_seed(context: TenantContext, *, refs: DemoRefs | None = None) -> DemoRefs: +def run_demo_seed( + context: TenantContext, *, refs: DemoRefs | None = None, recorder: object | None = None +) -> DemoRefs: """Запускает все loaders в порядке зависимостей. Предполагается вызов внутри ``tenant_atomic(context)``: tenant-контекст - активирован, RLS и storage-guard работают. + активирован, RLS и storage-guard работают. ``recorder`` — реестр + установленных записей (``registry.DemoRecorder``) для точного удаления; + loaders обращаются к нему только для записей, созданных без сигналов. """ refs = refs or DemoRefs() + refs.organization = context.organization + refs.recorder = recorder foundation_loader.load(context, refs) catalog_loader.load(context, refs) channels_ai_loader.load(context, refs) - conversations_loader.load(context, refs) + # Поддержка раньше диалогов: диалог портала ссылается на снимок личности, + # а статьи портала привязываются к агентам. support_loader.load(context, refs) + conversations_loader.load(context, refs) operations_loader.load(context, refs) return refs diff --git a/apps/backend/hub_platform/identity/demo_seed/refs.py b/apps/backend/hub_platform/identity/demo_seed/refs.py index 8a21495..ff4a50f 100644 --- a/apps/backend/hub_platform/identity/demo_seed/refs.py +++ b/apps/backend/hub_platform/identity/demo_seed/refs.py @@ -18,15 +18,24 @@ class DemoRefs: """ organization: object | None = None + recorder: object | None = None groups: dict[str, object] = field(default_factory=dict) memberships: dict[str, object] = field(default_factory=dict) users: dict[str, object] = field(default_factory=dict) products: dict[str, object] = field(default_factory=dict) integrations: dict[str, object] = field(default_factory=dict) channels: dict[str, object] = field(default_factory=dict) + agents: dict[str, object] = field(default_factory=dict) + widgets: dict[str, object] = field(default_factory=dict) + knowledge_categories: dict[str, object] = field(default_factory=dict) knowledge: dict[str, object] = field(default_factory=dict) + agent_article_links: dict[str, list[str]] = field(default_factory=dict) + labels: dict[str, object] = field(default_factory=dict) + portal_articles: dict[str, object] = field(default_factory=dict) contacts: dict[str, object] = field(default_factory=dict) identities: dict[str, object] = field(default_factory=dict) conversations: dict[str, object] = field(default_factory=dict) support_contracts: dict[str, object] = field(default_factory=dict) identity_snapshots: dict[str, object] = field(default_factory=dict) + calls: dict[str, object] = field(default_factory=dict) + portal: object | None = None diff --git a/apps/backend/hub_platform/identity/demo_seed/registry.py b/apps/backend/hub_platform/identity/demo_seed/registry.py new file mode 100644 index 0000000..a3e8bf3 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/registry.py @@ -0,0 +1,164 @@ +"""Реестр созданных сидом записей и их точное удаление. + +Запись: на время установки к ``post_save`` подключается приёмник, который +регистрирует каждую созданную (``created=True``) запись любой модели в +``DemoRecord`` с порядковым номером. Сид создаёт данные через сервисы +системы, поэтому реестр покрывает и побочные записи (аудит, уведомления, +фрагменты знаний, сессии веб-чата). + +Удаление: записи удаляются по одной в порядке, обратном созданию — так +зависимые строки уходят раньше родителей, а PROTECT-связи не мешают. +Каскадно удалённые ранее строки просто пропускаются. Файлы FileField +удаляются из хранилища вместе с записью. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager + +from django.contrib.contenttypes.models import ContentType +from django.db import models, transaction +from django.db.models.deletion import ProtectedError +from django.db.models.signals import post_save + +from hub_platform.identity.demo_models import DemoDataset, DemoRecord + +logger = logging.getLogger(__name__) + +# Модели, которые реестр не регистрирует: сам реестр и типы контента. +_IGNORED_MODELS = {DemoDataset, DemoRecord, ContentType} + + +class DemoRecorder: + """Собирает созданные записи в ``DemoRecord`` дата-сета.""" + + def __init__(self, dataset: DemoDataset) -> None: + self.dataset = dataset + self.sequence = ( + DemoRecord.objects.filter(dataset=dataset).order_by("-sequence") + .values_list("sequence", flat=True) + .first() + or 0 + ) + self._content_types: dict[type[models.Model], ContentType] = {} + + def _content_type(self, model: type[models.Model]) -> ContentType: + cached = self._content_types.get(model) + if cached is None: + cached = ContentType.objects.get_for_model(model, for_concrete_model=True) + self._content_types[model] = cached + return cached + + def on_post_save(self, sender, instance, created, raw=False, **kwargs) -> None: # noqa: ANN001 + if not created or raw or sender in _IGNORED_MODELS or sender._meta.abstract: + return + if sender._meta.app_label == "sessions": + return + self.sequence += 1 + DemoRecord.objects.create( + organization_id=self.dataset.organization_id, + dataset=self.dataset, + content_type=self._content_type(sender), + object_id=str(instance.pk), + sequence=self.sequence, + ) + + def record(self, instance: models.Model) -> None: + """Явная регистрация записи, созданной без сигнала (bulk_create).""" + self.on_post_save(type(instance), instance, True) + + +@contextmanager +def recording(dataset: DemoDataset) -> Iterator[DemoRecorder]: + recorder = DemoRecorder(dataset) + post_save.connect(recorder.on_post_save, dispatch_uid=f"demo-recorder-{dataset.pk}", weak=False) + try: + yield recorder + finally: + post_save.disconnect(dispatch_uid=f"demo-recorder-{dataset.pk}") + + +def _delete_files(instance: models.Model) -> None: + for field in instance._meta.get_fields(): + if isinstance(field, models.FileField): + file = getattr(instance, field.name, None) + if file and file.name: + try: + file.delete(save=False) + except Exception: # noqa: BLE001 — файл не должен блокировать удаление записи + logger.warning("Demo file %s was not deleted", file.name, exc_info=True) + + +def _detach_demo_references(instance: models.Model, demo_keys: set[tuple[int, str]]) -> bool: + """Обнуляет nullable-FK записи на другие демо-объекты — разрывает циклы + PROTECT (статья ↔ опубликованная ревизия). Только демо-объекты: реальные + данные не трогаются. Возвращает ``True``, если что-то обнулено.""" + updates: dict[str, None] = {} + for field in instance._meta.get_fields(): + if not isinstance(field, models.ForeignKey) or not field.null: + continue + target_id = getattr(instance, field.attname) + if target_id is None: + continue + target_ct = ContentType.objects.get_for_model(field.remote_field.model, for_concrete_model=True) + if (target_ct.id, str(target_id)) in demo_keys: + updates[field.attname] = None + if not updates: + return False + type(instance)._base_manager.filter(pk=instance.pk).update(**updates) + return True + + +def _delete_record(record: DemoRecord, demo_keys: set[tuple[int, str]]) -> bool: + """Удаляет запись реестра и её объект. ``False`` — объект защищён + (PROTECT) ещё живой записью; попытка повторяется позже.""" + model = record.content_type.model_class() + if model is None: + record.delete() + return True + instance = model._base_manager.filter(pk=record.object_id).first() + if instance is None: + record.delete() + return True + for attempt in range(2): + try: + with transaction.atomic(): + _delete_files(instance) + instance.delete() + break + except ProtectedError: + if attempt == 1 or not _detach_demo_references(instance, demo_keys): + return False + instance = model._base_manager.get(pk=record.object_id) + record.delete() + return True + + +def remove_records(dataset: DemoDataset) -> int: + """Удаляет все записи дата-сета в обратном порядке создания. + + Обратный порядок снимает почти все зависимости; оставшиеся защищённые + связи (например, статья портала ↔ её опубликованная ревизия) разрешаются + повторными проходами: после удаления родителя каскад забирает зависимую + запись, и на следующем проходе она уже отсутствует. + """ + removed = 0 + pending = list(dataset.records.select_related("content_type").order_by("-sequence")) + demo_keys = {(record.content_type_id, record.object_id) for record in pending} + while pending: + deferred: list[DemoRecord] = [] + for record in pending: + existed = record.content_type.model_class() is not None and ( + record.content_type.model_class()._base_manager.filter(pk=record.object_id).exists() + ) + if _delete_record(record, demo_keys): + removed += int(existed) + else: + deferred.append(record) + if len(deferred) == len(pending): + blocked = ", ".join(f"{r.content_type.model}:{r.object_id}" for r in deferred[:5]) + raise ProtectedError(f"Demo records cannot be deleted: {blocked}", set()) + pending = deferred + return removed diff --git a/apps/backend/hub_platform/identity/demo_seed/service.py b/apps/backend/hub_platform/identity/demo_seed/service.py new file mode 100644 index 0000000..616991f --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_seed/service.py @@ -0,0 +1,204 @@ +"""Установка и удаление демо-данных организации. + +Запрос (из мастера первого запуска или из «Настроек») фиксирует состояние +``DemoDataset`` и ставит outbox-событие; работу выполняет worker +(``identity.event_handlers``), чтобы HTTP-запрос не ждал минуты индексации +знаний и раскладки файлов. Карточка в настройках опрашивает ``demo_status``. +""" + +from __future__ import annotations + +import logging + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone + +from hub_platform.events.services import DomainEvent, enqueue_event +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus +from hub_platform.identity.demo_seed.orchestrator import run_demo_seed +from hub_platform.identity.demo_seed.registry import recording, remove_records +from hub_platform.identity.models import HumanUser, Organization +from hub_platform.tenancy.context import TenantContext + +logger = logging.getLogger(__name__) + +from hub_platform.identity.event_handlers import ( # noqa: E402 + DEMO_INSTALL_REQUESTED, + DEMO_REMOVE_REQUESTED, +) + + +class DemoBusy(ValidationError): + """Установка или удаление уже идут.""" + + +def showcase_accounts() -> list[dict[str, object]]: + """Учётки для входа «посмотреть глазами сотрудника»: админ и по одному + сотруднику из разных групп. Пароль общий и намеренно публичный — это демо.""" + from hub_platform.identity.demo_seed import manifest + + data = manifest.load("organization") + by_key = {item["key"]: item for item in data["accounts"]} + groups = {item["key"]: item["name"] for item in data.get("groups", [])} + accounts = [] + for key in data.get("showcaseAccounts", []): + item = by_key[key] + accounts.append( + { + "fullName": item["fullName"], + "email": item["email"], + "password": data["demoPassword"], + "role": item["role"], + "positionTitle": item.get("positionTitle", ""), + "groups": [groups[g] for g in item.get("groups", []) if g in groups], + } + ) + return accounts + + +def demo_status(organization: Organization) -> dict[str, object]: + dataset = DemoDataset.objects.filter(organization=organization).first() + if dataset is None: + return { + "status": "ABSENT", + "recordsCount": 0, + "error": "", + "startedAt": None, + "finishedAt": None, + "accounts": [], + } + installed = dataset.status == DemoDatasetStatus.INSTALLED + return { + "status": dataset.status, + "recordsCount": dataset.records_count, + "error": dataset.error, + "startedAt": dataset.started_at.isoformat(), + "finishedAt": dataset.finished_at.isoformat() if dataset.finished_at else None, + "accounts": showcase_accounts() if installed else [], + } + + +def _busy(dataset: DemoDataset | None) -> bool: + return dataset is not None and dataset.status in ( + DemoDatasetStatus.INSTALLING, + DemoDatasetStatus.REMOVING, + ) + + +def request_install(*, context: TenantContext, actor: HumanUser | None) -> DemoDataset: + """Ставит установку в очередь. Повторный запрос при установленном наборе — + ошибка: сначала удаление.""" + with transaction.atomic(): + dataset = ( + DemoDataset.objects.select_for_update() + .filter(organization_id=context.organization_id) + .first() + ) + if _busy(dataset): + raise DemoBusy({"demo": "Демо-данные сейчас устанавливаются или удаляются"}) + if dataset is not None and dataset.status == DemoDatasetStatus.INSTALLED: + raise ValidationError({"demo": "Демо-данные уже установлены"}) + if dataset is None: + dataset = DemoDataset(organization_id=context.organization_id) + dataset.status = DemoDatasetStatus.INSTALLING + dataset.requested_by = actor + dataset.error = "" + dataset.finished_at = None + dataset.started_at = timezone.now() + dataset.save() + record_audit_event( + organization=context.organization, + actor=actor, + action="demo.install_requested", + object_type="DemoDataset", + object_id=str(dataset.id), + ) + enqueue_event( + DomainEvent( + aggregate_type="DemoDataset", + aggregate_id=str(dataset.id), + event_type=DEMO_INSTALL_REQUESTED, + payload={"datasetId": dataset.id}, + tenant_context=context, + ) + ) + return dataset + + +def request_remove(*, context: TenantContext, actor: HumanUser | None) -> DemoDataset: + with transaction.atomic(): + dataset = ( + DemoDataset.objects.select_for_update() + .filter(organization_id=context.organization_id) + .first() + ) + if dataset is None: + raise ValidationError({"demo": "Демо-данные не установлены"}) + if _busy(dataset): + raise DemoBusy({"demo": "Демо-данные сейчас устанавливаются или удаляются"}) + dataset.status = DemoDatasetStatus.REMOVING + dataset.requested_by = actor + dataset.error = "" + dataset.finished_at = None + dataset.save(update_fields=["status", "requested_by", "error", "finished_at"]) + record_audit_event( + organization=context.organization, + actor=actor, + action="demo.remove_requested", + object_type="DemoDataset", + object_id=str(dataset.id), + ) + enqueue_event( + DomainEvent( + aggregate_type="DemoDataset", + aggregate_id=str(dataset.id), + event_type=DEMO_REMOVE_REQUESTED, + payload={"datasetId": dataset.id}, + tenant_context=context, + ) + ) + return dataset + + +def install(*, context: TenantContext, dataset: DemoDataset) -> DemoDataset: + """Выполняет сид под записью в реестр. Вызывается внутри tenant_atomic.""" + try: + # Savepoint: при сбое откатываются и данные, и их записи в реестре — + # набор либо полный, либо отсутствует. + with transaction.atomic(), recording(dataset) as recorder: + run_demo_seed(context, recorder=recorder) + dataset.records_count = dataset.records.count() + dataset.status = DemoDatasetStatus.INSTALLED + dataset.error = "" + except Exception as error: # noqa: BLE001 — статус ошибки должен дойти до UI + logger.exception("Demo install failed for organization %s", context.organization_id) + dataset.records_count = 0 + dataset.status = DemoDatasetStatus.FAILED + dataset.error = str(error)[:2000] + dataset.finished_at = timezone.now() + dataset.save(update_fields=["records_count", "status", "error", "finished_at"]) + record_audit_event( + organization=context.organization, + actor=dataset.requested_by, + action="demo.installed" if dataset.status == DemoDatasetStatus.INSTALLED else "demo.install_failed", + object_type="DemoDataset", + object_id=str(dataset.id), + payload={"recordsCount": dataset.records_count}, + ) + return dataset + + +def remove(*, context: TenantContext, dataset: DemoDataset) -> None: + """Удаляет записи по реестру и сам дата-сет. Вызывается внутри tenant_atomic.""" + removed = remove_records(dataset) + record_audit_event( + organization=context.organization, + actor=dataset.requested_by, + action="demo.removed", + object_type="DemoDataset", + object_id=str(dataset.id), + payload={"recordsCount": removed}, + ) + dataset.delete() diff --git a/apps/backend/hub_platform/identity/demo_views.py b/apps/backend/hub_platform/identity/demo_views.py new file mode 100644 index 0000000..daf7e83 --- /dev/null +++ b/apps/backend/hub_platform/identity/demo_views.py @@ -0,0 +1,75 @@ +"""Демо-данные организации: статус, установка, удаление («Настройки»).""" + +import mimetypes + +from django.core.exceptions import ValidationError +from django.http import FileResponse, Http404 +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from hub_platform.api.permissions import HasCapability +from hub_platform.identity.demo_seed import manifest, service + + +def _error(error: ValidationError, status: int) -> Response: + if hasattr(error, "message_dict"): + detail = "; ".join( + message for messages in error.message_dict.values() for message in messages + ) + else: + detail = "; ".join(error.messages) + return Response({"detail": detail}, status=status) + + +class DemoDataView(APIView): + permission_classes = [HasCapability] + required_capabilities = { + "GET": "settings.view", + "POST": "company.manage", + "DELETE": "company.manage", + } + require_organization_scope = True + + def get(self, request: Request) -> Response: + return Response(service.demo_status(request.tenant_context.organization)) + + def post(self, request: Request) -> Response: + try: + service.request_install(context=request.tenant_context, actor=request.user) + except service.DemoBusy as error: + return _error(error, 409) + except ValidationError as error: + return _error(error, 400) + return Response(service.demo_status(request.tenant_context.organization), status=202) + + def delete(self, request: Request) -> Response: + try: + service.request_remove(context=request.tenant_context, actor=request.user) + except service.DemoBusy as error: + return _error(error, 409) + except ValidationError as error: + return _error(error, 400) + return Response(service.demo_status(request.tenant_context.organization), status=202) + + +class DemoMediaView(APIView): + """Публичные медиа демо-набора (аватары контактов) из пакета бэкенда. + + Отдаёт только файлы каталога demo_seed/data/media/avatars; путь + нормализуется, выход за каталог невозможен. + """ + + authentication_classes: list = [] + permission_classes = [AllowAny] + + def get(self, request: Request, name: str): + root = (manifest.MEDIA_DIR / "avatars").resolve() + candidate = (root / name).resolve() + if root not in candidate.parents or not candidate.is_file(): + raise Http404("Demo media not found") + content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream" + response = FileResponse(candidate.open("rb"), content_type=content_type) + response["Cache-Control"] = "public, max-age=86400" + return response diff --git a/apps/backend/hub_platform/identity/event_handlers.py b/apps/backend/hub_platform/identity/event_handlers.py index e55a3ef..b4d9c62 100644 --- a/apps/backend/hub_platform/identity/event_handlers.py +++ b/apps/backend/hub_platform/identity/event_handlers.py @@ -37,3 +37,43 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None user = HumanUser.objects.filter(pk=payload.get("userId"), is_active=True).first() if user is not None: send_password_reset_email(user) + + +# --- Демо-данные (мастер первого запуска и «Настройки») ----------------------- + +DEMO_INSTALL_REQUESTED = "demo.install_requested" +DEMO_REMOVE_REQUESTED = "demo.remove_requested" + + +def _demo_dataset(payload: dict, context: TenantContext | None): + from hub_platform.identity.demo_models import DemoDataset + + if context is None: + raise ValueError("Demo dataset events are tenant events") + return ( + DemoDataset.objects.select_for_update() + .filter(pk=payload.get("datasetId"), organization_id=context.organization_id) + .first() + ) + + +@register(DEMO_INSTALL_REQUESTED) +def handle_demo_install_requested(payload: dict, context: TenantContext | None) -> None: + from hub_platform.identity.demo_models import DemoDatasetStatus + from hub_platform.identity.demo_seed import service + + dataset = _demo_dataset(payload, context) + if dataset is None or dataset.status != DemoDatasetStatus.INSTALLING: + return + service.install(context=context, dataset=dataset) + + +@register(DEMO_REMOVE_REQUESTED) +def handle_demo_remove_requested(payload: dict, context: TenantContext | None) -> None: + from hub_platform.identity.demo_models import DemoDatasetStatus + from hub_platform.identity.demo_seed import service + + dataset = _demo_dataset(payload, context) + if dataset is None or dataset.status != DemoDatasetStatus.REMOVING: + return + service.remove(context=context, dataset=dataset) diff --git a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py b/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py deleted file mode 100644 index 1353e20..0000000 --- a/apps/backend/hub_platform/identity/management/commands/bootstrap_owner.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.core.management.base import BaseCommand, CommandError - -from hub_platform.identity.bootstrap import bootstrap_edevs_owner - - -class Command(BaseCommand): - help = "Bootstraps the Edevs organization and first OWNER account." - - def add_arguments(self, parser) -> None: - parser.add_argument("--email", required=True) - parser.add_argument("--password", required=True) - parser.add_argument("--name", default="") - - def handle(self, *args: object, **options: object) -> None: - if len(options["password"]) < 12: - raise CommandError("Password must contain at least 12 characters.") - result = bootstrap_edevs_owner( - email=options["email"], - password=options["password"], - full_name=options["name"], - ) - state = "created" if result.created_owner else "already_exists" - self.stdout.write( - self.style.SUCCESS( - f"OWNER {state}: {result.owner.email}; org={result.organization.slug}; groups={result.operators_group.name},{result.support_group.name}" - ) - ) diff --git a/apps/backend/hub_platform/identity/management/commands/seed_demo.py b/apps/backend/hub_platform/identity/management/commands/seed_demo.py index 6c7c442..d8a4a5a 100644 --- a/apps/backend/hub_platform/identity/management/commands/seed_demo.py +++ b/apps/backend/hub_platform/identity/management/commands/seed_demo.py @@ -1,90 +1,69 @@ -"""Загружает полный набор демонстрационных данных организации «Северная Верфь». +"""Демо-данные «Ателье Норд» из командной строки (для разработки и CI). -Данные — выдуманные (РФ-наполнение), полностью покрывают значимые домены -системы. Источник — редактируемые JSON-манифесты в -``hub_platform/identity/demo_seed/data/``. - -Идемпотентен: повторный запуск не дублирует и не затирает записи. По умолчанию -работает в режиме dry-run (ничего не пишет); запуск с ``--apply`` применяет сид. -Вне режима отладки требует явного ``--force`` (например, при установке облака). +Штатный путь — мастер первого запуска и карточка «Демо-данные» в «Настройках». +Команда делает то же самое синхронно: ``--apply`` ставит набор в организацию +по slug, ``--remove`` удаляет его по реестру. Без флагов — сухой прогон. """ from __future__ import annotations -from django.conf import settings from django.core.management.base import BaseCommand, CommandError -from hub_platform.identity.demo_seed import manifest -from hub_platform.identity.demo_seed.orchestrator import run_demo_seed +from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus +from hub_platform.identity.demo_seed import service +from hub_platform.identity.models import Organization from hub_platform.tenancy.context import TenantActorKind, TenantContext from hub_platform.tenancy.database import tenant_atomic class Command(BaseCommand): - help = "Seeds the demo organization «Северная Верфь» with full fictional data." + help = "Installs or removes the «Ателье Норд» demo dataset for an organization." def add_arguments(self, parser) -> None: - parser.add_argument( - "--apply", - action="store_true", - help="Apply the seed. Without it, only a dry-run summary is printed.", - ) - parser.add_argument( - "--force", - action="store_true", - help="Allow running outside DEBUG (e.g. cloud installation).", - ) + parser.add_argument("--organization", required=True, help="Organization slug") + group = parser.add_mutually_exclusive_group() + group.add_argument("--apply", action="store_true", help="Install the demo dataset") + group.add_argument("--remove", action="store_true", help="Remove the installed demo dataset") def handle(self, *args: object, **options: object) -> None: - if not settings.DEBUG and not options["force"]: - raise CommandError( - "Demo seed is for local/cloud installation only. Pass --force to run outside DEBUG." - ) + try: + organization = Organization.objects.get(slug=options["organization"]) + except Organization.DoesNotExist as error: + raise CommandError(f"Organization {options['organization']!r} not found") from error + context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM) - org_data = manifest.load("organization")["organization"] - - if not options["apply"]: + if not options["apply"] and not options["remove"]: + status = service.demo_status(organization) self.stdout.write( self.style.WARNING( - f"Dry-run: would seed demo organization «{org_data['name']}» " - f"({org_data['slug']}). Pass --apply to create the data." + f"Dry-run: demo dataset for «{organization.name}» is {status['status']} " + f"({status['recordsCount']} records). Pass --apply or --remove." ) ) return - context = TenantContext.for_resource( - _resolve_organization(org_data), - actor_kind=TenantActorKind.SYSTEM, - ) with tenant_atomic(context): - refs = run_demo_seed(context) - - self.stdout.write( - self.style.SUCCESS( - "Demo seed applied: " - f"org={refs.organization.slug}; " - f"users={len(refs.users)}; " - f"products={len(refs.products)}; " - f"channels={len(refs.channels)}; " - f"conversations={len(refs.conversations)}" - ) - ) - - -def _resolve_organization(org_data: dict): - """Возвращает организацию сида, создавая её, если нужно. - - ``run_demo_seed`` (foundation-загрузчик) идемпотентно донастроит её поля; - здесь нужна только оболочка, чтобы построить TenantContext до старта. - """ - from hub_platform.identity.models import Organization - - organization, _ = Organization.objects.get_or_create( - slug=org_data["slug"], - defaults={ - "name": org_data["name"], - "timezone": org_data["timezone"], - "currency": org_data["currency"], - }, - ) - return organization + dataset = DemoDataset.objects.select_for_update().filter(organization=organization).first() + if options["apply"]: + if dataset is None: + dataset = DemoDataset.objects.create( + organization=organization, status=DemoDatasetStatus.INSTALLING + ) + elif dataset.status == DemoDatasetStatus.INSTALLED: + raise CommandError("Demo dataset is already installed; remove it first") + else: + dataset.status = DemoDatasetStatus.INSTALLING + dataset.save(update_fields=["status"]) + dataset = service.install(context=context, dataset=dataset) + if dataset.status != DemoDatasetStatus.INSTALLED: + raise CommandError(f"Demo install failed: {dataset.error}") + self.stdout.write( + self.style.SUCCESS( + f"Demo installed for «{organization.name}»: {dataset.records_count} records" + ) + ) + else: + if dataset is None: + raise CommandError("Demo dataset is not installed") + service.remove(context=context, dataset=dataset) + self.stdout.write(self.style.SUCCESS(f"Demo removed for «{organization.name}»")) diff --git a/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py b/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py new file mode 100644 index 0000000..3079535 --- /dev/null +++ b/apps/backend/hub_platform/identity/migrations/0022_demo_dataset_registry.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.15 on 2026-09-05 12:50 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('identity', '0021_humanuser_ui_accent_humanuser_ui_theme'), + ] + + operations = [ + migrations.CreateModel( + name='DemoDataset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('INSTALLING', 'Устанавливается'), ('INSTALLED', 'Установлены'), ('REMOVING', 'Удаляются'), ('FAILED', 'Ошибка')], default='INSTALLING', max_length=16)), + ('records_count', models.PositiveIntegerField(default=0)), + ('error', models.TextField(blank=True, default='')), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('organization', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='demo_dataset', to='identity.organization')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'identity_demodataset', + }, + ), + migrations.CreateModel( + name='DemoRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(max_length=64)), + ('sequence', models.PositiveBigIntegerField()), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='identity.demodataset')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='identity.organization')), + ], + options={ + 'db_table': 'identity_demorecord', + 'indexes': [models.Index(fields=['dataset', 'sequence'], name='identity_de_dataset_33366a_idx')], + 'constraints': [models.UniqueConstraint(fields=('dataset', 'content_type', 'object_id'), name='identity_demorecord_unique')], + }, + ), + ] diff --git a/apps/backend/hub_platform/identity/models.py b/apps/backend/hub_platform/identity/models.py index fad815c..19aa2e0 100644 --- a/apps/backend/hub_platform/identity/models.py +++ b/apps/backend/hub_platform/identity/models.py @@ -239,3 +239,7 @@ from hub_platform.identity.group_models import ( # noqa: E402, F401 from hub_platform.identity.invitation_models import ( # noqa: E402, F401 OrganizationInvitation, ) +from hub_platform.identity.demo_models import ( # noqa: E402, F401 + DemoDataset, + DemoRecord, +) diff --git a/apps/backend/hub_platform/identity/setup.py b/apps/backend/hub_platform/identity/setup.py new file mode 100644 index 0000000..730cc26 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup.py @@ -0,0 +1,166 @@ +"""Мастер первого запуска (SPEC-HUB-0031, open source: «развернуть за минуту»). + +Пока в инстансе нет ни одной организации, публичный эндпоинт /api/v1/setup/ +принимает одну форму: название организации, имя, e-mail и пароль владельца. +Никаких параметров в .env и CLI: всё задаёт человек в браузере. После +создания владельца мастер закрывается навсегда (409). + +Запись идёт на соединении ``platform`` — единственной runtime-роли с правом +создавать организации (SPEC-HUB-0021 §10); RLS-контекст и транзакция живут +на том же соединении. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.conf import settings +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.db import connections, transaction +from django.utils.text import slugify + +from hub_platform.ai.knowledge_categories import ensure_uncategorized_category +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, + OrganizationStatus, +) +from hub_platform.tenancy.context import TenantActorKind, TenantContext +from hub_platform.tenancy.database import tenant_atomic +from hub_platform.tenancy.routing import use_database + +ORGANIZATION_NAME_MAX_LENGTH = 255 +FULL_NAME_MAX_LENGTH = 255 + +# Алиас соединения для операций уровня инстанса. В тестах оба алиаса — +# зеркала одной тестовой БД под ролью-владельцем кластера. +INSTANCE_DB_ALIAS = "default" if settings.TESTING else "platform" + + +class SetupAlreadyCompleted(Exception): + """Организация уже есть — мастер закрыт.""" + + +@dataclass(frozen=True) +class SetupInput: + organization_name: str + full_name: str + email: str + password: str + install_demo: bool = False + + +@dataclass(frozen=True) +class SetupResult: + organization: Organization + owner: HumanUser + + +def instance_needs_setup() -> bool: + """Мастер нужен, пока не создана ни одна организация. + + Организации видны роли app целиком (RLS SELECT USING true), поэтому + проверка не требует tenant-контекста. + """ + with use_database(INSTANCE_DB_ALIAS): + return not Organization.objects.exists() + + +def _clean(data: SetupInput) -> SetupInput: + errors: dict[str, str] = {} + organization_name = data.organization_name.strip() + if not organization_name: + errors["organizationName"] = "Укажите название организации" + elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH: + errors["organizationName"] = "Название длиннее 255 символов" + full_name = " ".join(data.full_name.split()) + if not full_name: + errors["fullName"] = "Укажите ваше имя" + elif len(full_name) > FULL_NAME_MAX_LENGTH: + errors["fullName"] = "Имя длиннее 255 символов" + email = data.email.strip() + try: + validate_email(email) + except ValidationError: + errors["email"] = "Укажите корректный e-mail" + if errors: + raise ValidationError(errors) + return SetupInput( + organization_name=organization_name, + full_name=full_name, + email=HumanUser.objects.normalize_email(email).lower(), + password=data.password, + install_demo=data.install_demo, + ) + + +def _unique_slug(name: str) -> str: + base = slugify(name)[:40].strip("-") or "organization" + candidate = base + suffix = 2 + while Organization.objects.filter(slug=candidate).exists(): + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def complete_setup(data: SetupInput) -> SetupResult: + clean = _clean(data) + with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS): + # Блокировка от гонки двух вкладок: второй запрос дождётся первого и + # увидит созданную организацию. + with connections[INSTANCE_DB_ALIAS].cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(hashtext('chatbolls.instance_setup'))") + if Organization.objects.exists(): + raise SetupAlreadyCompleted() + + # Пароль проверяется против атрибутов будущего пользователя (схожесть + # с e-mail и именем) до записи чего-либо. + probe = HumanUser(email=clean.email, full_name=clean.full_name) + validate_password(clean.password, user=probe) + + organization = Organization.objects.create( + name=clean.organization_name, + slug=_unique_slug(clean.organization_name), + status=OrganizationStatus.ACTIVE, + ) + with tenant_atomic(organization.id, using=INSTANCE_DB_ALIAS): + ensure_uncategorized_category(organization) + owner = HumanUser.objects.create_user( + email=clean.email, + password=clean.password, + full_name=clean.full_name, + is_staff=True, + is_superuser=True, + ) + OrganizationMembership.objects.create( + user=owner, + organization=organization, + role=EmployeeRole.OWNER, + position_title="Владелец", + totp_required=False, + ) + record_audit_event( + organization=organization, + actor=owner, + action="identity.instance_setup_completed", + object_type="Organization", + object_id=str(organization.public_id), + payload={"organizationName": organization.name, "installDemo": clean.install_demo}, + ) + if clean.install_demo: + # Демо ставит worker по outbox-событию; форма не ждёт. + from hub_platform.identity.demo_seed.service import request_install + + request_install( + context=TenantContext.for_resource( + organization, actor_kind=TenantActorKind.SYSTEM, actor_user=owner + ), + actor=owner, + ) + return SetupResult(organization=organization, owner=owner) diff --git a/apps/backend/hub_platform/identity/setup_urls.py b/apps/backend/hub_platform/identity/setup_urls.py new file mode 100644 index 0000000..8434045 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup_urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from hub_platform.identity import setup_views + +urlpatterns = [ + path("", setup_views.SetupStatusView.as_view(), name="setup-status"), + path("complete/", setup_views.SetupView.as_view(), name="setup-complete"), +] diff --git a/apps/backend/hub_platform/identity/setup_views.py b/apps/backend/hub_platform/identity/setup_views.py new file mode 100644 index 0000000..4b1d9e8 --- /dev/null +++ b/apps/backend/hub_platform/identity/setup_views.py @@ -0,0 +1,75 @@ +from django.contrib.auth import login +from django.core.exceptions import ValidationError +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView + +from hub_platform.identity.auth.common import _user_payload +from hub_platform.identity.setup import ( + SetupAlreadyCompleted, + SetupInput, + complete_setup, + instance_needs_setup, +) + +SETUP_CLOSED = {"detail": "Первый запуск уже выполнен: организация создана"} + + +def _validation_response(error: ValidationError) -> Response: + if hasattr(error, "message_dict"): + errors = { + key: messages[0] if isinstance(messages, list) else str(messages) + for key, messages in error.message_dict.items() + } + # validate_password кладёт сообщения без ключа поля. + if "__all__" in errors: + errors["password"] = " ".join(error.message_dict["__all__"]) + del errors["__all__"] + detail = next(iter(errors.values()), "Проверьте заполненные поля") + return Response({"detail": detail, "errors": errors}, status=400) + message = " ".join(error.messages) + return Response({"detail": message, "errors": {"password": message}}, status=400) + + +@method_decorator(ensure_csrf_cookie, name="dispatch") +class SetupStatusView(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + + def get(self, request: Request) -> Response: + return Response({"needsSetup": instance_needs_setup()}) + + +@method_decorator(csrf_protect, name="dispatch") +class SetupView(APIView): + authentication_classes: list = [] + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "login" + + def post(self, request: Request) -> Response: + body = request.data if isinstance(request.data, dict) else {} + if not instance_needs_setup(): + return Response(SETUP_CLOSED, status=409) + try: + result = complete_setup( + SetupInput( + organization_name=str(body.get("organizationName", "")), + full_name=str(body.get("fullName", "")), + email=str(body.get("email", "")), + password=str(body.get("password", "")), + install_demo=bool(body.get("installDemo", False)), + ) + ) + except SetupAlreadyCompleted: + return Response(SETUP_CLOSED, status=409) + except ValidationError as error: + return _validation_response(error) + owner = result.owner + owner.backend = "django.contrib.auth.backends.ModelBackend" + login(request, owner) + return Response({"authenticated": True, "user": _user_payload(owner)}, status=201) diff --git a/apps/backend/hub_platform/identity/test_seed_demo.py b/apps/backend/hub_platform/identity/test_seed_demo.py index 8ee4ca5..45dbd17 100644 --- a/apps/backend/hub_platform/identity/test_seed_demo.py +++ b/apps/backend/hub_platform/identity/test_seed_demo.py @@ -1,93 +1,225 @@ -from io import StringIO +"""Демо-набор «Ателье Норд»: полное покрытие моделей, точное удаление, API.""" -from django.core.management import call_command +from __future__ import annotations + +import json +import tempfile + +from django.apps import apps +from django.db import models from django.test import TestCase, override_settings +from hub_platform.testing import TenantAPIClient as APIClient -from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment -from hub_platform.calls.models import CallSession -from hub_platform.conversations.models import Conversation, Message -from hub_platform.identity.demo_seed.orchestrator import DemoRefs, run_demo_seed -from hub_platform.identity.models import ( - EmployeeRole, - HumanUser, - Organization, +from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeAttachment +from hub_platform.conversations.models import ( + Contact, + Conversation, + LifecycleState, + Message, + MessageKind, ) -from hub_platform.notifications.models import Notification -from hub_platform.products.models import Product -from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot -from hub_platform.support_portals.models import PortalArticle, SupportPortal +from hub_platform.events.handlers import dispatch +from hub_platform.events.models import OutboxEvent +from hub_platform.identity.demo_models import DemoDataset, DemoDatasetStatus, DemoRecord +from hub_platform.identity.demo_seed import service +from hub_platform.identity.models import HumanUser, Organization, OrganizationMembership +from hub_platform.identity.setup import SetupInput, complete_setup from hub_platform.tenancy.context import TenantActorKind, TenantContext from hub_platform.tenancy.database import tenant_atomic -DEMO_SLUG = "severnaya-verf" +_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-demo-media-") + +OWNER = { + "organization_name": "Моё ателье", + "full_name": "Елена Кузнецова", + "email": "owner@atelie.test", + "password": "Owner-Setup-2026!", +} + +# Модели, которые демо-набор не обязан заполнять: сам реестр, организация +# установщика (уже есть), платформенный контур и очередь событий. +COVERAGE_EXEMPT = { + ("identity", "organization"), + ("identity", "demodataset"), + ("identity", "demorecord"), +} +COVERAGE_EXEMPT_APPS = {"platform", "events"} -def _run_seed() -> DemoRefs: - organization = Organization.objects.get(slug=DEMO_SLUG) - context = TenantContext.for_resource(organization, actor_kind=TenantActorKind.SYSTEM) - with tenant_atomic(context): - return run_demo_seed(context) +def _covered_models() -> list[type[models.Model]]: + result = [] + for model in apps.get_models(): + label = model._meta.app_label + if not model.__module__.startswith("hub_platform."): + continue + if label in COVERAGE_EXEMPT_APPS or (label, model._meta.model_name) in COVERAGE_EXEMPT: + continue + result.append(model) + return result -class SeedDemoTests(TestCase): - """Покрывает создание, идемпотентность и dry-run демо-сида «Северная Верфь».""" +def _counts() -> dict[str, int]: + return {m._meta.label: m._base_manager.count() for m in _covered_models()} + + +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) +class DemoDatasetTests(TestCase): + def setUp(self) -> None: + result = complete_setup(SetupInput(**OWNER)) + self.organization: Organization = result.organization + self.owner: HumanUser = result.owner + self.context = TenantContext.for_resource( + self.organization, actor_kind=TenantActorKind.SYSTEM, actor_user=self.owner + ) + + def _install(self) -> DemoDataset: + with tenant_atomic(self.context): + dataset = DemoDataset.objects.create( + organization=self.organization, status=DemoDatasetStatus.INSTALLING + ) + return service.install(context=self.context, dataset=dataset) + + def test_install_covers_every_model_of_the_system(self) -> None: + before = _counts() + dataset = self._install() + + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLED, dataset.error) + after = _counts() + missing = sorted(label for label, count in after.items() if count <= before[label]) + self.assertEqual(missing, [], f"demo dataset leaves models empty: {missing}") + self.assertGreater(dataset.records_count, 200) + + def test_dataset_matches_the_design_baseline(self) -> None: + self._install() + organization = self.organization + + # Люди: владелец установщика + 6 демо-сотрудников, один заблокирован. + memberships = OrganizationMembership.objects.filter(organization=organization) + self.assertEqual(memberships.count(), 7) + self.assertEqual(memberships.filter(blocked_at__isnull=False).count(), 1) + anna = HumanUser.objects.get(email="a.kim@atelie-nord.ru") + self.assertTrue(anna.check_password("Chatbolls-Demo-2026")) + self.assertTrue(HumanUser.objects.get(email="i.saveliev@atelie-nord.ru").totp_enabled) + + # Агенты: активный с AI, черновик без AI, выключенный канал. + agents = AIAgent.objects.filter(channel__organization=organization) + self.assertEqual(agents.filter(status=AIAgentStatus.ACTIVE).count(), 2) + self.assertEqual(agents.filter(status=AIAgentStatus.DRAFT).count(), 1) + self.assertEqual(organization.channels.filter(is_active=False).count(), 1) + + # Знания: категории двух уровней, вложения, отключённое знание. + self.assertGreaterEqual(KnowledgeAttachment.objects.filter(organization=organization).count(), 3) + self.assertEqual(Knowledge.objects.filter(organization=organization, is_enabled=False).count(), 1) + + # Диалоги во всех состояниях (кадры A–H). + conversations = Conversation.objects.filter(organization=organization) + self.assertEqual(conversations.filter(lifecycle=LifecycleState.SPAM).count(), 1) + self.assertGreaterEqual(conversations.filter(lifecycle=LifecycleState.CLOSED).count(), 3) + self.assertEqual(conversations.filter(archived_at__isnull=False).count(), 1) + self.assertGreaterEqual(conversations.filter(control_mode="PAUSED", lifecycle="OPEN").count(), 3) + self.assertTrue(conversations.filter(assigned_operator=anna).exists()) + self.assertTrue(conversations.filter(previous_conversation__isnull=False).exists()) + self.assertTrue(conversations.filter(labels__name="Срочно").exists()) + # История контакта: у Марии два диалога. + maria = Contact.objects.get(organization=organization, name="Мария Соколова") + self.assertEqual(conversations.filter(contact=maria).count(), 2) + self.assertTrue(maria.avatar_url.startswith("/api/v1/demo-media/avatars/")) + # Веб-гость получил настоящую сессию виджета. + self.assertTrue(conversations.filter(contact__name__startswith="Веб-гость").exists()) + # Сообщения: контакт-шаринг и системные события есть; голосовые — при наличии файлов. + messages = Message.objects.filter(conversation__organization=organization) + self.assertTrue(messages.filter(kind=MessageKind.CONTACT).exists()) + self.assertTrue(messages.filter(author_type="SYSTEM").exists()) + + def test_remove_restores_the_exact_state_before_install(self) -> None: + before = _counts() + dataset = self._install() + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLED, dataset.error) + + with tenant_atomic(self.context): + service.remove(context=self.context, dataset=DemoDataset.objects.get(pk=dataset.pk)) + + after = _counts() + # Аудит установки и удаления — единственный след администраторских действий. + after["identity.AuditEvent"] = before["identity.AuditEvent"] + self.assertEqual(after, before) + self.assertFalse(DemoDataset.objects.exists()) + self.assertFalse(DemoRecord.objects.exists()) + # Организация и владелец установщика на месте. + self.assertTrue(Organization.objects.filter(pk=self.organization.pk).exists()) + self.assertTrue(HumanUser.objects.filter(pk=self.owner.pk).exists()) + + +@override_settings(MEDIA_ROOT=_MEDIA_ROOT) +class DemoDatasetApiTests(TestCase): + """Карточка «Демо-данные»: очередь через outbox, worker выполняет.""" def setUp(self) -> None: - # Организация создаётся командой; здесь нужен только shell для контекста. - Organization.objects.create(name="Северная Верфь", slug=DEMO_SLUG) + complete_setup(SetupInput(**OWNER)) + self.organization = Organization.objects.get() + self.client = APIClient() + self.client.login(username=OWNER["email"], password=OWNER["password"]) - def test_creates_full_dataset(self) -> None: - refs = _run_seed() + def _dispatch_pending(self) -> None: + for event in OutboxEvent.objects.filter(event_type__startswith="demo.").order_by("id"): + dispatch(event) + event.delete() - self.assertEqual(refs.organization.slug, DEMO_SLUG) - # Персонал: владелец, админ, 2 продажи, 2 поддержки. - self.assertEqual(refs.organization.memberships.count(), 6) - self.assertEqual( - refs.organization.memberships.filter(role=EmployeeRole.EMPLOYEE).count(), 4 + def test_install_and_remove_through_api_and_worker(self) -> None: + self.assertEqual(self.client.get("/api/v1/company/demo/").json()["status"], "ABSENT") + + queued = self.client.post("/api/v1/company/demo/") + self.assertEqual(queued.status_code, 202) + self.assertEqual(queued.json()["status"], "INSTALLING") + # Повторный запрос во время установки отклоняется. + self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 409) + + self._dispatch_pending() + installed = self.client.get("/api/v1/company/demo/").json() + self.assertEqual(installed["status"], "INSTALLED", installed["error"]) + self.assertGreater(installed["recordsCount"], 200) + # Витринные учётки: админ и сотрудники из разных групп, с паролем. + accounts = installed["accounts"] + self.assertEqual([a["email"] for a in accounts], ["a.kim@atelie-nord.ru", "d.orlov@atelie-nord.ru", "a.gusev@atelie-nord.ru"]) + self.assertEqual(accounts[1]["groups"], ["Консультанты"]) + self.assertEqual(accounts[2]["groups"], ["Поддержка"]) + self.assertTrue(all(a["password"] == "Chatbolls-Demo-2026" for a in accounts)) + self.assertEqual(self.client.post("/api/v1/company/demo/").status_code, 400) + + removing = self.client.delete("/api/v1/company/demo/") + self.assertEqual(removing.status_code, 202) + self.assertEqual(removing.json()["status"], "REMOVING") + self._dispatch_pending() + self.assertEqual(self.client.get("/api/v1/company/demo/").json()["status"], "ABSENT") + self.assertFalse(HumanUser.objects.filter(email="a.kim@atelie-nord.ru").exists()) + + def test_employee_cannot_manage_demo(self) -> None: + self.client.post("/api/v1/company/demo/") + self._dispatch_pending() + employee = APIClient() + employee.login(username="d.orlov@atelie-nord.ru", password="Chatbolls-Demo-2026") + self.assertEqual(employee.post("/api/v1/company/demo/").status_code, 403) + self.assertEqual(employee.delete("/api/v1/company/demo/").status_code, 403) + + + +class SetupWizardDemoTests(TestCase): + def test_setup_wizard_queues_demo_install(self) -> None: + # Пустой инстанс: мастер с флагом installDemo ставит набор в очередь. + response = APIClient().post( + "/api/v1/setup/complete/", + data=json.dumps( + { + "organizationName": "Ателье Норд", + "fullName": "Елена Кузнецова", + "email": "e.kuznetsova@atelie-nord.ru", + "password": "Nord-Atelier-2026!", + "installDemo": True, + } + ), + content_type="application/json", ) - # Продукты. - self.assertEqual(Product.objects.filter(organization=refs.organization).count(), 2) - # Каналы и AI-агенты. - self.assertGreaterEqual(refs.organization.channels.count(), 6) - self.assertGreaterEqual(AIAgent.objects.count(), 3) - # База знаний со вложениями. - self.assertGreaterEqual(Knowledge.objects.filter(organization=refs.organization).count(), 4) - self.assertGreaterEqual(KnowledgeAttachment.objects.count(), 2) - # Диалоги и сообщения. - conversations = Conversation.objects.filter(organization=refs.organization) - self.assertGreaterEqual(conversations.count(), 8) - self.assertGreaterEqual(Message.objects.count(), 10) - # Поддержка: контракты, снимки, портал со статьями. - self.assertGreaterEqual(ProductSupportContract.objects.count(), 2) - self.assertGreaterEqual(SupportIdentitySnapshot.objects.count(), 2) - self.assertGreaterEqual(PortalArticle.objects.count(), 6) - self.assertEqual(SupportPortal.objects.count(), 1) - # Операции: звонки и уведомления. - self.assertGreaterEqual(CallSession.objects.count(), 1) - self.assertGreaterEqual(Notification.objects.count(), 3) - - def test_idempotent_rerun(self) -> None: - _run_seed() - counts_before = { - "users": HumanUser.objects.filter(email__icontains="severnayaverf.ru").count(), - "products": Product.objects.filter(organization__slug=DEMO_SLUG).count(), - "conversations": Conversation.objects.filter(organization__slug=DEMO_SLUG).count(), - "messages": Message.objects.filter(conversation__organization__slug=DEMO_SLUG).count(), - } - _run_seed() - counts_after = { - "users": HumanUser.objects.filter(email__icontains="severnayaverf.ru").count(), - "products": Product.objects.filter(organization__slug=DEMO_SLUG).count(), - "conversations": Conversation.objects.filter(organization__slug=DEMO_SLUG).count(), - "messages": Message.objects.filter(conversation__organization__slug=DEMO_SLUG).count(), - } - self.assertEqual(counts_before, counts_after) - - @override_settings(DEBUG=True) - def test_dry_run_creates_nothing(self) -> None: - before = Conversation.objects.filter(organization__slug=DEMO_SLUG).count() - out = StringIO() - call_command("seed_demo", stdout=out) - after = Conversation.objects.filter(organization__slug=DEMO_SLUG).count() - self.assertEqual(before, after) - self.assertIn("Dry-run", out.getvalue()) + self.assertEqual(response.status_code, 201, response.content) + dataset = DemoDataset.objects.get() + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLING) + self.assertTrue(OutboxEvent.objects.filter(event_type="demo.install_requested").exists()) diff --git a/apps/backend/hub_platform/identity/test_setup.py b/apps/backend/hub_platform/identity/test_setup.py new file mode 100644 index 0000000..089086f --- /dev/null +++ b/apps/backend/hub_platform/identity/test_setup.py @@ -0,0 +1,103 @@ +import json + +from django.test import TestCase +from hub_platform.testing import TenantAPIClient as APIClient + +from hub_platform.ai.models import KnowledgeCategory +from hub_platform.identity.models import ( + AuditEvent, + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) + +VALID = { + "organizationName": "Ателье Норд", + "fullName": "Елена Кузнецова", + "email": "E.Kuznetsova@atelie-nord.ru", + "password": "Nord-Atelier-2026!", +} + + +class SetupWizardTests(TestCase): + """Мастер первого запуска: одна форма в браузере, никаких параметров в .env.""" + + def setUp(self) -> None: + self.client = APIClient() + + def complete(self, **overrides): + body = {**VALID, **overrides} + return self.client.post( + "/api/v1/setup/complete/", data=json.dumps(body), content_type="application/json" + ) + + def test_fresh_instance_needs_setup(self) -> None: + response = self.client.get("/api/v1/setup/") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["needsSetup"]) + + def test_complete_creates_organization_owner_and_logs_in(self) -> None: + response = self.complete() + + self.assertEqual(response.status_code, 201, response.content) + payload = response.json() + self.assertTrue(payload["authenticated"]) + user = payload["user"] + self.assertEqual(user["email"], "e.kuznetsova@atelie-nord.ru") + self.assertEqual(user["fullName"], "Елена Кузнецова") + self.assertFalse(user["mustChangePassword"]) + self.assertEqual(len(user["memberships"]), 1) + self.assertEqual(user["memberships"][0]["role"], EmployeeRole.OWNER) + self.assertEqual(user["memberships"][0]["organizationName"], "Ателье Норд") + + organization = Organization.objects.get() + # Кириллическое имя не даёт slug — берётся нейтральный. + self.assertEqual(organization.slug, "organization") + owner = HumanUser.objects.get(email="e.kuznetsova@atelie-nord.ru") + self.assertTrue(owner.check_password(VALID["password"])) + self.assertTrue(owner.is_superuser) + membership = OrganizationMembership.objects.get(user=owner, organization=organization) + self.assertEqual(membership.role, EmployeeRole.OWNER) + self.assertTrue(KnowledgeCategory.objects.filter(organization=organization).exists()) + self.assertTrue( + AuditEvent.objects.filter( + organization=organization, action="identity.instance_setup_completed" + ).exists() + ) + + # Сессия установлена: приложение сразу открывается под владельцем. + session = self.client.get("/api/v1/auth/session/") + self.assertTrue(session.json()["authenticated"]) + + def test_latin_name_becomes_slug(self) -> None: + self.complete(organizationName="Nord Atelier & Co") + self.assertEqual(Organization.objects.get().slug, "nord-atelier-co") + + def test_setup_closes_after_first_owner(self) -> None: + self.assertEqual(self.complete().status_code, 201) + + self.assertFalse(self.client.get("/api/v1/setup/").json()["needsSetup"]) + second = APIClient().post( + "/api/v1/setup/complete/", + data=json.dumps({**VALID, "email": "other@atelie-nord.ru"}), + content_type="application/json", + ) + self.assertEqual(second.status_code, 409) + self.assertEqual(Organization.objects.count(), 1) + self.assertEqual(HumanUser.objects.count(), 1) + + def test_validation_errors_name_the_fields(self) -> None: + response = self.complete(organizationName=" ", fullName="", email="not-an-email") + self.assertEqual(response.status_code, 400) + self.assertEqual( + set(response.json()["errors"]), {"organizationName", "fullName", "email"} + ) + self.assertFalse(Organization.objects.exists()) + + def test_weak_password_is_rejected_before_anything_is_written(self) -> None: + response = self.complete(password="password") + self.assertEqual(response.status_code, 400) + self.assertIn("password", response.json()["errors"]) + self.assertFalse(Organization.objects.exists()) + self.assertFalse(HumanUser.objects.exists()) diff --git a/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py b/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py new file mode 100644 index 0000000..0f362a7 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/migrations/0022_demo_registry_guards.py @@ -0,0 +1,49 @@ +# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы +# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль +# получает те же права: мастер первого запуска ставит демо-набор в очередь на +# соединении platform (SPEC-HUB-0021 §10). +from django.db import migrations + +TABLES = ("identity_demodataset", "identity_demorecord") + +FORWARD_RLS = """ +ALTER TABLE {table} OWNER TO custocrm_schema; +ALTER TABLE {table} ENABLE ROW LEVEL SECURITY; +ALTER TABLE {table} FORCE ROW LEVEL SECURITY; +REVOKE ALL ON {table} FROM PUBLIC; +GRANT SELECT, INSERT, UPDATE, DELETE ON {table} + TO custocrm_runtime_app, custocrm_runtime_platform; +GRANT ALL ON {table} TO custocrm_schema; +GRANT USAGE, SELECT ON SEQUENCE {table}_id_seq + TO custocrm_runtime_app, custocrm_runtime_platform, custocrm_schema; +DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}; +CREATE POLICY custocrm_tenant_isolation ON {table} + FOR ALL TO custocrm_runtime_app, custocrm_runtime_platform + USING (organization_id = custocrm.current_organization_id()) + WITH CHECK (organization_id = custocrm.current_organization_id()); +DROP POLICY IF EXISTS custocrm_schema_access ON {table}; +CREATE POLICY custocrm_schema_access ON {table} + FOR ALL TO custocrm_schema USING (true) WITH CHECK (true); +""" + + +def apply_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(FORWARD_RLS.format(table=table)) + + +def remove_guards(apps, schema_editor): + for table in TABLES: + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_tenant_isolation ON {table}") + schema_editor.execute(f"DROP POLICY IF EXISTS custocrm_schema_access ON {table}") + schema_editor.execute(f"ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY") + schema_editor.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY") + + +class Migration(migrations.Migration): + dependencies = [ + ("tenancy", "0021_chat_extras_guards"), + ("identity", "0022_demo_dataset_registry"), + ] + + operations = [migrations.RunPython(apply_guards, remove_guards)] diff --git a/apps/backend/hub_platform/tenancy/routing.py b/apps/backend/hub_platform/tenancy/routing.py new file mode 100644 index 0000000..024d723 --- /dev/null +++ b/apps/backend/hub_platform/tenancy/routing.py @@ -0,0 +1,45 @@ +"""Принудительный выбор соединения БД для блока кода. + +Runtime backend-app держит два соединения: ``default`` (роль app) и +``platform`` (роль platform, права на создание организаций — SPEC-HUB-0021). +Обычные запросы идут в ``default``; операции уровня инстанса (мастер первого +запуска) выполняются целиком на ``platform`` через ``use_database("platform")``, +чтобы транзакция и RLS-контекст жили на одном соединении. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +_forced_alias: ContextVar[str | None] = ContextVar("hub_forced_db_alias", default=None) + + +@contextmanager +def use_database(alias: str) -> Iterator[None]: + token = _forced_alias.set(alias) + try: + yield + finally: + _forced_alias.reset(token) + + +def forced_database() -> str | None: + return _forced_alias.get() + + +class ForcedAliasRouter: + """Django database router: внутри ``use_database`` все запросы идут в алиас.""" + + def db_for_read(self, model, **hints): # noqa: ANN001, ARG002 + return _forced_alias.get() + + def db_for_write(self, model, **hints): # noqa: ANN001, ARG002 + return _forced_alias.get() + + def allow_relation(self, obj1, obj2, **hints): # noqa: ANN001, ARG002 + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: ANN001, ARG002 + return None diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index abd305b..45bcf3e 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -8,7 +8,7 @@ import { applyAppearance, DEFAULT_ACCENT, resolvedDark } from "./shared/appearan import { api, setActiveOrganization } from "./api/client"; import { canAccess, defaultRoute, isManager } from "./auth/access"; import { activateOrganization, clearOrganizationPreference } from "./auth/session"; -import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; +import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens"; import { Shell } from "./layout/Shell"; import { pathFromRoute, routeFromPath } from "./router"; import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui"; @@ -34,6 +34,8 @@ export function App() { const [totpChallenge, setTotpChallenge] = useState(null); const [recovering, setRecovering] = useState(false); const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password"); + // Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания. + const [needsSetup, setNeedsSetup] = useState(false); const [data, setData] = useState({ employees: [], groups: [], products: [], agents: [] }); const [dataError, setDataError] = useState(false); const navigation = useRouteNavigation(initialRoute, organizationPublicId); @@ -88,8 +90,11 @@ export function App() { ); window.history.replaceState({}, "", nextPath); } + return undefined; } + return api<{ needsSetup: boolean }>("/api/v1/setup/").then((setup) => setNeedsSetup(setup.needsSetup)); }) + .catch(() => undefined) .finally(() => setSessionLoading(false)); }, [initialRoute, useIdentity]); @@ -147,7 +152,9 @@ export function App() { {totpChallenge ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> ) : !identity ? ( - recovering ? ( + needsSetup ? ( + { setNeedsSetup(false); landAfterAuth(nextUser); }} /> + ) : recovering ? ( setRecovering(false)} /> ) : ( setRecovering(true)} /> diff --git a/apps/internal-ui/src/features/auth/AuthField.tsx b/apps/internal-ui/src/features/auth/AuthField.tsx index 9651778..0c730ae 100644 --- a/apps/internal-ui/src/features/auth/AuthField.tsx +++ b/apps/internal-ui/src/features/auth/AuthField.tsx @@ -14,7 +14,7 @@ export function AuthField({ }: { children?: ReactNode; error?: boolean; - icon: "lock" | "mail"; + icon: "lock" | "mail" | "building" | "user"; onChange: (value: string) => void; placeholder: string; type?: "password" | "text"; diff --git a/apps/internal-ui/src/features/auth/AuthScreens.tsx b/apps/internal-ui/src/features/auth/AuthScreens.tsx index 75ada25..bd262d0 100644 --- a/apps/internal-ui/src/features/auth/AuthScreens.tsx +++ b/apps/internal-ui/src/features/auth/AuthScreens.tsx @@ -2,5 +2,6 @@ export { AuthChangePassword } from "./AuthChangePassword"; export { AuthLogin } from "./AuthLogin"; export { AuthPasswordRecovery } from "./AuthPasswordRecovery"; export { AuthResetPassword } from "./AuthResetPassword"; +export { AuthSetup } from "./AuthSetup"; export { AuthTotpCode } from "./AuthTotpCode"; export { AuthTotpSetup } from "./AuthTotpSetup"; diff --git a/apps/internal-ui/src/features/auth/AuthSetup.tsx b/apps/internal-ui/src/features/auth/AuthSetup.tsx new file mode 100644 index 0000000..228ef10 --- /dev/null +++ b/apps/internal-ui/src/features/auth/AuthSetup.tsx @@ -0,0 +1,92 @@ +import { type FormEvent, useState } from "react"; + +import { api, ApiError } from "../../api/client"; +import type { AuthenticatedUser } from "../../types"; +import { Icon } from "../../shared/icons"; +import { Button } from "../../shared/ui-controls"; +import { AuthField } from "./AuthField"; +import { AuthFrame } from "./AuthFrame"; +import { passwordIsValid, passwordLabels, passwordScore } from "./password"; + +type SetupErrors = Partial>; + +/** + * Мастер первого запуска: пока в инстансе нет организации, вместо входа — + * одна форма. Никаких параметров в .env: всё задаёт человек здесь. + */ +export function AuthSetup({ onDone }: { onDone: (user: AuthenticatedUser) => void }) { + const [organizationName, setOrganizationName] = useState(""); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [show, setShow] = useState(false); + const [installDemo, setInstallDemo] = useState(true); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const score = passwordScore(password); + const valid = organizationName.trim() !== "" && fullName.trim() !== "" && email.trim() !== "" && passwordIsValid(password, false); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!valid) return; + setSubmitting(true); + setError(""); + setErrors({}); + try { + const payload = await api<{ authenticated: true; user: AuthenticatedUser }>("/api/v1/setup/complete/", { + method: "POST", + body: JSON.stringify({ organizationName, fullName, email, password, installDemo }), + }); + onDone(payload.user); + } catch (requestError) { + if (requestError instanceof ApiError) { + const fieldErrors = (requestError.payload as { errors?: SetupErrors }).errors; + if (fieldErrors) setErrors(fieldErrors); + setError(requestError.message); + } else { + setError("Не удалось создать организацию"); + } + } finally { + setSubmitting(false); + } + } + + return ( + +
+ {error &&
!{error}
} + + + + + + + + + + +
+
{[0, 1, 2, 3].map((item) => )}
+

{passwordLabels[score]}

+
+
+ ТРЕБОВАНИЯ К ПАРОЛЮ + = 10 ? "done" : ""}>Не менее 10 символов + Содержит цифру + Буквы и спецсимвол +
+ + + +
+ ); +} diff --git a/apps/internal-ui/src/features/auth/styles.css b/apps/internal-ui/src/features/auth/styles.css index 81baaa5..2a9445c 100644 --- a/apps/internal-ui/src/features/auth/styles.css +++ b/apps/internal-ui/src/features/auth/styles.css @@ -239,6 +239,44 @@ color: var(--success-text); } +.auth-checkbox { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 20px; + padding: 13px 14px; + background: var(--n-10); + border: 1px solid var(--n-8); + border-radius: 10px; + cursor: pointer; +} + +.auth-checkbox input { + flex: none; + width: 16px; + height: 16px; + margin: 2px 0 0; + accent-color: var(--primary); +} + +.auth-checkbox span { + display: flex; + flex-direction: column; + gap: 3px; +} + +.auth-checkbox strong { + color: var(--n-2); + font-size: 13px; + font-weight: 600; +} + +.auth-checkbox small { + color: var(--n-4); + font-size: 12px; + line-height: 1.4; +} + .auth-totp-setup-card { padding: 26px; } diff --git a/apps/internal-ui/src/features/settings/DemoDataCard.tsx b/apps/internal-ui/src/features/settings/DemoDataCard.tsx new file mode 100644 index 0000000..c93d1ee --- /dev/null +++ b/apps/internal-ui/src/features/settings/DemoDataCard.tsx @@ -0,0 +1,167 @@ +import { Modal } from "antd"; +import { useCallback, useEffect, useState } from "react"; + +import { api } from "../../api/client"; +import { Button } from "../../shared/ui-controls"; + +// Секция «Демо-данные» экрана настроек: владелец или админ ставит и удаляет +// вымышленную организацию «Ателье Норд» одной кнопкой. Работу выполняет +// worker; карточка опрашивает статус, пока идёт установка или удаление. + +export type DemoStatus = "ABSENT" | "INSTALLING" | "INSTALLED" | "REMOVING" | "FAILED"; + +export type DemoAccount = { + fullName: string; + email: string; + password: string; + role: "OWNER" | "ADMIN" | "EMPLOYEE"; + positionTitle: string; + groups: string[]; +}; + +export type DemoState = { + status: DemoStatus; + recordsCount: number; + error: string; + startedAt: string | null; + finishedAt: string | null; + accounts: DemoAccount[]; +}; + +const BUSY: DemoStatus[] = ["INSTALLING", "REMOVING"]; +const POLL_INTERVAL_MS = 2000; + +export function demoStatusLabel(state: DemoState): string { + switch (state.status) { + case "INSTALLED": + return "Установлены"; + case "INSTALLING": + return "Устанавливаются"; + case "REMOVING": + return "Удаляются"; + case "FAILED": + return "Ошибка установки"; + default: + return "Не установлены"; + } +} + +export function DemoDataCard({ reload }: { reload: () => void }) { + const [state, setState] = useState(null); + const [error, setError] = useState(""); + const [confirmRemove, setConfirmRemove] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(async () => { + try { + setState(await api("/api/v1/company/demo/")); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось получить статус демо-данных"); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const busy = state !== null && BUSY.includes(state.status); + useEffect(() => { + if (!busy) return undefined; + const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [busy, load]); + + // Когда работа завершилась — обновить данные приложения (сотрудники, агенты). + const [wasBusy, setWasBusy] = useState(false); + useEffect(() => { + if (busy) setWasBusy(true); + else if (wasBusy) { + setWasBusy(false); + reload(); + } + }, [busy, reload, wasBusy]); + + async function install() { + setSubmitting(true); + setError(""); + try { + setState(await api("/api/v1/company/demo/", { method: "POST" })); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось запустить установку"); + } finally { + setSubmitting(false); + } + } + + async function remove() { + setSubmitting(true); + setError(""); + try { + setState(await api("/api/v1/company/demo/", { method: "DELETE" })); + setConfirmRemove(false); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось запустить удаление"); + } finally { + setSubmitting(false); + } + } + + const label = state ? demoStatusLabel(state) : null; + const installed = state?.status === "INSTALLED"; + + return ( +
+
+

Демо-данные

+ {label && {label}} +
+

+ Вымышленное ателье «Норд»: сотрудники и группы, агенты с подключениями, знания, диалоги во всех состояниях, + голосовые, шаблоны ответов, портал поддержки, звонки и уведомления. Удаляются целиком — ваши данные не затрагиваются. +

+ {state?.status === "INSTALLED" && ( + <> +

Записей: {state.recordsCount}. Войдите под демо-сотрудником в другом браузере или приватном окне, чтобы увидеть систему его глазами:

+
+ {state.accounts.map((account) => ( +
+
+ {account.fullName} + {account.role === "EMPLOYEE" ? "Сотрудник" : "Администратор"} · {account.groups.length ? account.groups.join(", ") : "без групп"} +
+ {account.email} + {account.password} +
+ ))} +
+ + )} + {state?.status === "FAILED" && state.error &&
{state.error}
} + {error &&
{error}
} +
+ {!installed && ( + + )} + {(installed || state?.status === "REMOVING") && ( + + )} +
+ {confirmRemove && ( + setConfirmRemove(false)} footer={null} destroyOnClose> +
+

Будут удалены все записи демо-набора: сотрудники, агенты, диалоги, знания и файлы. Данные, созданные вами, останутся.

+ {error &&
{error}
} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index a10a454..6df8005 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import { ProfileSessionsCard } from "../profile/ProfileSessionsCard"; import { ProfileTotpCard } from "../profile/ProfileTotpCard"; import { useProfilePage } from "../profile/useProfilePage"; import { EmptyState, LoadingState } from "../../shared/ui"; +import { DemoDataCard } from "./DemoDataCard"; import { GroupsSettingsCard } from "./GroupsSettingsCard"; // «Настройки» (SPEC-HUB-0031 §8.6): один экран с вертикальными секциями — @@ -52,6 +53,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [] }: { use + + + {profileSections} ); diff --git a/apps/internal-ui/src/features/settings/styles.css b/apps/internal-ui/src/features/settings/styles.css index dd7cde7..b2c0ecf 100644 --- a/apps/internal-ui/src/features/settings/styles.css +++ b/apps/internal-ui/src/features/settings/styles.css @@ -108,3 +108,83 @@ display: flex; justify-content: flex-end; } + +.settings-demo-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.settings-demo-head h3 { + margin: 0; +} + +.settings-demo-actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.settings-demo-status { + color: var(--n-4); + font-size: 12.5px; + font-weight: 500; +} + +.settings-demo-status.is-installed { + color: var(--success-text); +} + +.settings-demo-status.is-failed { + color: var(--error-text); +} + +.settings-demo-accounts { + display: flex; + flex-direction: column; + margin-top: 4px; +} + +.settings-demo-account { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + align-items: center; + gap: 14px; + padding: 8px 2px; + border-bottom: 1px solid var(--n-9); +} + +.settings-demo-account > div { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-demo-account strong { + color: var(--n-1); + font-size: 13.5px; + font-weight: 600; +} + +.settings-demo-account small { + color: var(--n-4); + font-size: 12px; +} + +.settings-demo-account code { + padding: 3px 8px; + border-radius: 6px; + background: var(--n-10); + border: 1px solid var(--n-8); + color: var(--n-2); + font-size: 12.5px; + user-select: all; +} + +@media (max-width: 720px) { + .settings-demo-account { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/compose.dev.yaml b/compose.dev.yaml index 7b39c21..d3687ac 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -118,32 +118,6 @@ services: - /app/node_modules - /app/apps/web-chat/node_modules - # Явный одноразовый импорт демонстрационных данных. Сервис существует только - # в compose.dev.yaml, профилем не включается в обычный `up`. Данные живут в - # backend image (management-команда seed_demo), отдельный bind-mount не нужен. - demo-seed: - profiles: ["demo-seed"] - build: - context: . - dockerfile: apps/backend/Dockerfile - env_file: - - .env - command: ["python", "manage.py", "seed_demo", "--apply"] - working_dir: /app/apps/backend - environment: - CUS_DB_ROLE: migration - CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD} - CUS_HELP_BASE_DOMAIN: localhost - CUS_HELP_PUBLIC_SCHEME: http - CUS_HELP_PUBLIC_PORT: "" - volumes: - - ./apps/backend:/app/apps/backend - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - # Dev: gateway — nginx с local.conf вместо Caddy (та же маршрутизация, HMR-friendly). gateway: image: nginx:1.27-alpine diff --git a/compose.yaml b/compose.yaml index 5716487..803c615 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,8 +49,8 @@ services: retries: 10 start_period: 20s - # One-shot init: миграции (ADR-HUB-0028 §one-shot init). Не запускает Edevs seed, - # не создаёт OWNER, не трогает demo — это отдельные ответственности (этап 2). + # One-shot init: только миграции (ADR-HUB-0028 §one-shot init). Владельца и + # демо-данные создаёт мастер первого запуска в браузере (/api/v1/setup/). # collectstatic испечён в backend-образ, здесь не вызывается. init: image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} diff --git a/scripts/seed-demo.ps1 b/scripts/seed-demo.ps1 deleted file mode 100644 index f58a6a6..0000000 --- a/scripts/seed-demo.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$ErrorActionPreference = "Stop" - -# Одноразовый импорт демо-данных организации «Северная Верфь» (выдуманные данные, -# РФ-наполнение). Профиль demo-seed определён только в compose.dev.yaml и не -# запускается обычным `docker compose up`. Сама команда `seed_demo` идемпотентна. -# Запускайте только после того, как локальный dev-стек поднят. -docker compose -f compose.yaml -f compose.dev.yaml --profile demo-seed --env-file .env.example --env-file .env ` - run --rm demo-seed diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..70fa8ee --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +# Локальный запуск Chatbolls (Linux/macOS): canonical compose.yaml + dev override. +# Параметры не нужны: владельца и демо-данные создаёт мастер первого запуска +# в браузере (http://localhost). Режим поставки: ./scripts/start.sh [cloud|self-hosted] +set -eu + +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + cp .env.example .env +fi + +MODE="${1:-cloud}" +case "$MODE" in + cloud) export CUS_DELIVERY_MODE=CLOUD ;; + self-hosted) export CUS_DELIVERY_MODE=SELF_HOSTED ;; + *) echo "usage: $0 [cloud|self-hosted]" >&2; exit 2 ;; +esac + +exec docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up --build