mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(seed): replace standalone importer with Django seed_demo command and Russian IIoT dataset
Старый сид dev/local-seed/ (standalone-скрипт под CUS_ENV) заменён полноценной Django-командой seed_demo с редактируемыми JSON-манифестами в demo_seed/data/. Полный выдуманный датасет организации «Северная Верфь» (IIoT, РФ-наполнение): продукты Вектор/Репер, каналы, AI-агенты, база знаний со вложениями, диалоги, заказы, продажи, поддержка, портал Help-центра, звонки, уведомления. Идемпотентен (get_or_create/update_or_create), dry-run по умолчанию (--apply), --force для облака. Удалён dev/local-seed/, скрипты seed-local/seed-support-portal, compose-профиль переключен на demo-seed. Тесты: создание, идемпотентность, dry-run.
This commit is contained in:
1 parent
12f189cc60
commit
4c622c7542
33 files changed
+2334
-909
No files matched your search
@@ -7,6 +7,7 @@ instance/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
apps/backend/staticfiles/
|
||||
apps/backend/media/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
@@ -54,36 +54,36 @@ Bootstrap the organization and both accounts:
|
||||
docker compose run --rm backend-app python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров"
|
||||
```
|
||||
|
||||
## Optional local demo seed
|
||||
## Optional demo seed
|
||||
|
||||
The local demo seed is explicit and idempotent: it creates the documented local
|
||||
accounts and a connected set of departments, products, channels, integrations,
|
||||
AI agents, conversations, orders, and command-center data. It never runs during
|
||||
`start.ps1`, `docker compose up`, or migrations.
|
||||
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-local.ps1
|
||||
.\scripts\seed-demo.ps1
|
||||
```
|
||||
|
||||
The data manifest and importer live in `dev/local-seed/`. The explicit
|
||||
`local-seed` one-shot service is defined only by `compose.dev.yaml`, runs with
|
||||
the local migration DB role, is excluded from the backend image, and the
|
||||
importer refuses any environment other than `CUS_ENV=local`. Production compose
|
||||
has no seed service or seed command.
|
||||
Or directly via management command (dry-run by default, `--apply` to write):
|
||||
|
||||
Демо-контент портала FoxRay и отдельный анонимный Web-виджет поддержки можно
|
||||
установить независимо от общего seed:
|
||||
|
||||
```powershell
|
||||
.\scripts\seed-support-portal.ps1
|
||||
```
|
||||
python manage.py seed_demo --apply
|
||||
```
|
||||
|
||||
Этот portal-only импорт идемпотентен и не создаёт пользователей, не меняет
|
||||
пароли, роли, сессии или права доступа. Он публикует локальный портал
|
||||
`http://foxray.localhost/`, категории и статьи, а также подключает существующий
|
||||
Web Chat через отдельный канал `foxray-help`.
|
||||
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.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Демонстрационные данные для локального тестирования и установки облака.
|
||||
|
||||
Пакет читает редактируемые JSON-манифесты из :mod:`demo_seed.data` и через
|
||||
:func:`hub_platform.identity.demo_seed.orchestrator.run_demo_seed` создаёт
|
||||
полный набор выдуманных данных организации «Северная Верфь». Идемпотентен:
|
||||
повторный запуск не дублирует и не затирает существующие записи.
|
||||
"""
|
||||
|
||||
from hub_platform.identity.demo_seed.orchestrator import run_demo_seed
|
||||
|
||||
__all__ = ["run_demo_seed"]
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"products": [
|
||||
{
|
||||
"code": "vektor",
|
||||
"name": "Вектор",
|
||||
"siteUrl": "https://vektor.severnayaverf.ru",
|
||||
"departments": ["sales", "support"]
|
||||
},
|
||||
{
|
||||
"code": "reper",
|
||||
"name": "Репер",
|
||||
"siteUrl": "https://reper.severnayaverf.ru",
|
||||
"departments": ["sales", "support"]
|
||||
}
|
||||
],
|
||||
"offers": [
|
||||
{
|
||||
"key": "vektor-box",
|
||||
"product": "vektor",
|
||||
"code": "vektor-box",
|
||||
"name": "Вектор — коробочная лицензия",
|
||||
"description": "Постоянная лицензия на сервер SCADA-мониторинга оборудования для одного производственного участка. Развёртывается на площадке заказчика.",
|
||||
"fulfillmentType": "BOX_LICENSE",
|
||||
"paymentType": "ONE_TIME",
|
||||
"fiscalName": "Лицензия на ПО «Вектор» (коробочная)",
|
||||
"aiOfferable": true
|
||||
},
|
||||
{
|
||||
"key": "vektor-support",
|
||||
"product": "vektor",
|
||||
"code": "vektor-support-12m",
|
||||
"name": "Вектор — продление поддержки на 12 месяцев",
|
||||
"description": "Доступ к обновлениям и технической поддержке коробочной лицензии Вектор. Привязывается к основной лицензии.",
|
||||
"fulfillmentType": "SUPPORT_EXTENSION",
|
||||
"paymentType": "SUBSCRIPTION",
|
||||
"primaryBoxOffer": "vektor-box",
|
||||
"fiscalName": "Продление поддержки «Вектор» (12 мес.)",
|
||||
"aiOfferable": false
|
||||
},
|
||||
{
|
||||
"key": "reper-cloud",
|
||||
"product": "reper",
|
||||
"code": "reper-cloud",
|
||||
"name": "Репер — облачная подписка",
|
||||
"description": "Доступ к платформе телематики транспорта и спецтехники в облаке. Оплата за парк техники помесячно.",
|
||||
"fulfillmentType": "SAAS_ACCESS",
|
||||
"paymentType": "SUBSCRIPTION",
|
||||
"fiscalName": "Подписка на сервис «Репер»",
|
||||
"aiOfferable": true
|
||||
}
|
||||
],
|
||||
"prices": [
|
||||
{
|
||||
"key": "vektor-box-price",
|
||||
"offer": "vektor-box",
|
||||
"billingPeriod": "ONE_TIME",
|
||||
"amountMinor": 1290000,
|
||||
"currency": "RUB"
|
||||
},
|
||||
{
|
||||
"key": "vektor-support-price",
|
||||
"offer": "vektor-support",
|
||||
"billingPeriod": "YEAR",
|
||||
"amountMinor": 189000,
|
||||
"currency": "RUB"
|
||||
},
|
||||
{
|
||||
"key": "reper-cloud-month",
|
||||
"offer": "reper-cloud",
|
||||
"billingPeriod": "MONTH",
|
||||
"amountMinor": 24000,
|
||||
"currency": "RUB"
|
||||
},
|
||||
{
|
||||
"key": "reper-cloud-year",
|
||||
"offer": "reper-cloud",
|
||||
"billingPeriod": "YEAR",
|
||||
"amountMinor": 264000,
|
||||
"currency": "RUB"
|
||||
}
|
||||
],
|
||||
"salesSources": [
|
||||
{
|
||||
"key": "vektor-source",
|
||||
"product": "vektor",
|
||||
"code": "vektor-product-api",
|
||||
"environment": "PRODUCTION",
|
||||
"status": "ACTIVE",
|
||||
"credentialHint": "demo-vektor-2026"
|
||||
},
|
||||
{
|
||||
"key": "reper-source",
|
||||
"product": "reper",
|
||||
"code": "reper-product-api",
|
||||
"environment": "PRODUCTION",
|
||||
"status": "ACTIVE",
|
||||
"credentialHint": "demo-reper-2026"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"integrations": [
|
||||
{ "key": "llm-provider", "provider": "OPENROUTER", "name": "OpenRouter (CustoAI)", "kind": "LLM_PROVIDER", "channel": null, "status": "OK" },
|
||||
{ "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": "Северная Верфь — главный сайт",
|
||||
"department": "sales",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "vektor-sales",
|
||||
"name": "Вектор — продажи",
|
||||
"department": "sales",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reper-sales",
|
||||
"name": "Репер — продажи",
|
||||
"department": "sales",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "vektor-support",
|
||||
"name": "Вектор — поддержка",
|
||||
"department": "support",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reper-support",
|
||||
"name": "Репер — поддержка",
|
||||
"department": "support",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "partners",
|
||||
"name": "Партнёрская линия",
|
||||
"department": null,
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "reper-help-widget",
|
||||
"name": "Репер — виджет Help-центра",
|
||||
"department": "support",
|
||||
"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
|
||||
}
|
||||
}
|
||||
],
|
||||
"agents": [
|
||||
{
|
||||
"channel": "site-main",
|
||||
"name": "Главный ассистент сайта",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"status": "ACTIVE",
|
||||
"persona": "Виртуальный ассистент компании «Северная Верфь» — производителя промышленной телеметрии. Помогает посетителям сайта сориентироваться в продуктах Вектор и Репер.",
|
||||
"tone": "Дружелюбный, деловой, без излишней фамильярности. Обращается на «вы».",
|
||||
"instructions": "Если вопрос касается конкретного продукта или условий покупки — переключай диалог на менеджера продаж. Не обещай скидки и сроки, которых нет в базе знаний.",
|
||||
"knowledge": ["vektor-overview", "reper-overview"]
|
||||
},
|
||||
{
|
||||
"channel": "vektor-sales",
|
||||
"name": "Менеджер продаж Вектора",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"status": "ACTIVE",
|
||||
"persona": "Отвечает за продажи коробочной лицензии Вектор производственным предприятиям.",
|
||||
"tone": "Деловой, экспертный, по сути.",
|
||||
"instructions": "Уточняй масштаб производства и число участков — от этого зависит конфигурация. Предлагай продление поддержки как сопутствующую услугу.",
|
||||
"knowledge": ["vektor-overview", "vektor-deploy"]
|
||||
},
|
||||
{
|
||||
"channel": "reper-sales",
|
||||
"name": "Менеджер продаж Репера",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"status": "ACTIVE",
|
||||
"persona": "Отвечает за продажи облачной подписки на телематику Репер.",
|
||||
"tone": "Деловой, ориентируется на выгоду клиента.",
|
||||
"instructions": "Уточняй размер парка техники и типы транспорта. Для парков от 50 единиц предлагай годовую оплату — она выгоднее помесячной.",
|
||||
"knowledge": ["reper-overview", "reper-telematics"]
|
||||
}
|
||||
],
|
||||
"knowledge": [
|
||||
{
|
||||
"key": "vektor-overview",
|
||||
"title": "Вектор — обзор продукта",
|
||||
"description": "Краткое описание SCADA-системы Вектор для предпродажных консультаций.",
|
||||
"content": "Вектор — сервер SCADA-мониторинга промышленного оборудования. Разворачивается на площадке заказчика, поставляется коробочной лицензией на один производственный участок. Поддерживает опрос контроллеров по OPC UA и Modbus, сбор метрик в реальном времени, алёртинг при выходе параметров за допуск.",
|
||||
"visibility": "ORGANIZATION"
|
||||
},
|
||||
{
|
||||
"key": "vektor-deploy",
|
||||
"title": "Вектор — руководство по развёртыванию",
|
||||
"description": "Требования к серверу и шаги развёртывания коробочной лицензии. Приложен файл с детальным руководством.",
|
||||
"content": "Подробное руководство по развёртыванию коробочной лицензии Вектор приведено во вложении. Требуется сервер с ОС Linux, 8 ГБ ОЗУ, доступ к контроллерам по сети.",
|
||||
"visibility": "DEPARTMENTS",
|
||||
"attachments": ["vektor-deploy-guide.md"]
|
||||
},
|
||||
{
|
||||
"key": "reper-overview",
|
||||
"title": "Репер — обзор продукта",
|
||||
"description": "Краткое описание телематики транспорта для предпродажных консультаций.",
|
||||
"content": "Репер — облачная платформа телематики для транспорта и спецтехники. Сбор данных с бортовых контроллеров, контроль топлива, моточасов, геозон. Оплата по подписке за единицу техники.",
|
||||
"visibility": "ORGANIZATION"
|
||||
},
|
||||
{
|
||||
"key": "reper-telematics",
|
||||
"title": "Репер — спецификация телематики",
|
||||
"description": "Поддерживаемые протоколы и бортовые контроллеры. Приложен файл со спецификацией.",
|
||||
"content": "Поддерживаемые протоколы обмена с бортовыми контроллерами и список совместимого оборудования приведены во вложении.",
|
||||
"visibility": "DEPARTMENTS",
|
||||
"attachments": ["reper-telematics-spec.md"]
|
||||
}
|
||||
],
|
||||
"llmInvocations": [
|
||||
{
|
||||
"channel": "vektor-sales",
|
||||
"product": "vektor",
|
||||
"purpose": "demo-chat-sales",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"promptTokens": 5200,
|
||||
"completionTokens": 1380,
|
||||
"costMicros": 312000,
|
||||
"status": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"orders": [
|
||||
{
|
||||
"externalId": "demo-ord-001",
|
||||
"product": "vektor",
|
||||
"contact": "e.zaitseva",
|
||||
"conversation": "vektor-closed-deal",
|
||||
"channel": "vektor-sales",
|
||||
"paymentStatus": "PAID",
|
||||
"fulfillmentStatus": "DELIVERED",
|
||||
"amountMinor": 1479000,
|
||||
"items": [
|
||||
{ "offer": "vektor-box", "price": "vektor-box-price", "title": "Вектор — коробочная лицензия", "quantity": 1, "amountMinor": 1290000 },
|
||||
{ "offer": "vektor-support", "price": "vektor-support-price", "title": "Продление поддержки 12 мес.", "quantity": 1, "amountMinor": 189000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"externalId": "demo-ord-002",
|
||||
"product": "reper",
|
||||
"contact": "p.gusev",
|
||||
"conversation": "reper-lead-active",
|
||||
"channel": "reper-sales",
|
||||
"paymentStatus": "PENDING",
|
||||
"fulfillmentStatus": "PENDING",
|
||||
"amountMinor": 264000,
|
||||
"items": [
|
||||
{ "offer": "reper-cloud", "price": "reper-cloud-year", "title": "Репер — подписка на 12 мес. (60 ед.)", "quantity": 1, "amountMinor": 264000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"externalId": "demo-ord-003",
|
||||
"product": "reper",
|
||||
"contact": "v.lebedeva",
|
||||
"conversation": "reper-quote",
|
||||
"channel": "reper-sales",
|
||||
"paymentStatus": "PAID",
|
||||
"fulfillmentStatus": "FAILED",
|
||||
"amountMinor": 140000,
|
||||
"items": [
|
||||
{ "offer": "reper-cloud", "price": "reper-cloud-month", "title": "Репер — подписка (32 ед.)", "quantity": 1, "amountMinor": 140000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"externalId": "demo-ord-004",
|
||||
"product": "vektor",
|
||||
"contact": "n.kuznetsova",
|
||||
"conversation": "vektor-lead-active",
|
||||
"channel": "vektor-sales",
|
||||
"paymentStatus": "PENDING",
|
||||
"fulfillmentStatus": "PENDING",
|
||||
"amountMinor": 1290000,
|
||||
"items": [
|
||||
{ "offer": "vektor-box", "price": "vektor-box-price", "title": "Вектор — коробочная лицензия", "quantity": 1, "amountMinor": 1290000 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"sales": [
|
||||
{
|
||||
"salesSource": "vektor-source",
|
||||
"eventId": "demo-sale-evt-001",
|
||||
"eventType": "sale.confirmed",
|
||||
"externalSaleId": "demo-sale-001",
|
||||
"amountMinor": 1290000,
|
||||
"currency": "RUB",
|
||||
"daysAgo": 25,
|
||||
"items": [
|
||||
{ "name": "Вектор — коробочная лицензия", "quantity": 1, "amount_minor": 1290000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"salesSource": "reper-source",
|
||||
"eventId": "demo-sale-evt-002",
|
||||
"eventType": "sale.confirmed",
|
||||
"externalSaleId": "demo-sale-002",
|
||||
"amountMinor": 264000,
|
||||
"currency": "RUB",
|
||||
"daysAgo": 9,
|
||||
"items": [
|
||||
{ "name": "Репер — подписка на 12 мес.", "quantity": 1, "amount_minor": 264000 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"salesSource": "reper-source",
|
||||
"eventId": "demo-sale-evt-003",
|
||||
"eventType": "sale.partially_refunded",
|
||||
"externalSaleId": "demo-sale-002",
|
||||
"amountMinor": 264000,
|
||||
"refundedAmountMinor": 40000,
|
||||
"currency": "RUB",
|
||||
"daysAgo": 2,
|
||||
"metadata": { "reason": "Списание за неиспользуемые единицы по обращению клиента" }
|
||||
}
|
||||
],
|
||||
"externalCustomers": [
|
||||
{ "product": "vektor", "externalCustomerId": "demo-vektor-cust-001", "contact": "e.zaitseva" },
|
||||
{ "product": "reper", "externalCustomerId": "demo-reper-cust-001", "contact": "p.gusev" }
|
||||
],
|
||||
"attributionTokens": [
|
||||
{
|
||||
"product": "vektor",
|
||||
"offer": "vektor-box",
|
||||
"contact": "n.kuznetsova",
|
||||
"conversation": "vektor-lead-active",
|
||||
"channel": "vektor-sales",
|
||||
"connection": "vektor-sales-tg",
|
||||
"actorType": "AI_AGENT",
|
||||
"actorId": "vektor-sales-agent",
|
||||
"ttlDays": 30
|
||||
},
|
||||
{
|
||||
"product": "reper",
|
||||
"offer": "reper-cloud",
|
||||
"contact": "p.gusev",
|
||||
"conversation": "reper-lead-active",
|
||||
"channel": "reper-sales",
|
||||
"connection": "reper-sales-tg",
|
||||
"actorType": "AI_AGENT",
|
||||
"actorId": "reper-sales-agent",
|
||||
"ttlDays": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"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" }
|
||||
],
|
||||
"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" }
|
||||
],
|
||||
"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",
|
||||
"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": "<abc123.def456@technosoft.ru>" },
|
||||
"messages": [
|
||||
{
|
||||
"externalId": "<abc123.def456@technosoft.ru>",
|
||||
"author": "CONTACT",
|
||||
"text": "Добрый день!\n\nПодскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.\n\nС уважением,\nФёдор Григорьев\nООО «ТехноСофт»",
|
||||
"contentHtml": "<div><p>Добрый день!</p><p>Подскажите, пожалуйста, условия продления технической поддержки Вектор на следующий год. Лицензия В-4712, срок поддержки заканчивается в августе.</p><p>С уважением,<br>Фёдор Григорьев<br>ООО «ТехноСофт»</p></div>"
|
||||
},
|
||||
{
|
||||
"externalId": "demo-email-reply-1",
|
||||
"author": "OPERATOR",
|
||||
"authorUser": "support_engineer",
|
||||
"text": "Здравствуйте, Фёдор! Продление поддержки на 12 месяцев для лицензии В-4712 — 189 000 ₽. Подготовлю счёт, на какую почту направить?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Репер — спецификация телематики
|
||||
|
||||
## Поддерживаемые протоколы
|
||||
|
||||
| Протокол | Назначение |
|
||||
|---|---|
|
||||
| Teltonika | Бортовые контроллеры серии FMC/FMB |
|
||||
| Wialon IPS | Совместимые трекеры сторонних производителей |
|
||||
| Galileosky | Контроллеры Galileosky v2/v3 |
|
||||
|
||||
## Совместимое оборудование
|
||||
|
||||
- **Бортовые контроллеры:** Teltonika FMB130/FMB140, Galileosky Base/Smart.
|
||||
- **Датчики уровня топлива (ДУТ):** Омником, Эскорт, LLS.
|
||||
- **Датчики моточасов:** входы дискретных и аналоговых датчиков.
|
||||
|
||||
## Частота опроса
|
||||
|
||||
- Положение: раз в 30 секунд при движении, раз в 5 минут на стоянке.
|
||||
- Топливо: непрерывно, усреднение за минуту.
|
||||
|
||||
## Атрибутика единицы техники
|
||||
|
||||
- Госномер, модель, тип (грузовик / спецтехника / прицеп).
|
||||
- Привязка к бортовому контроллеру по идентификатору (IMEI).
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Минимальный интервал данных — 10 секунд.
|
||||
- Один бортовой контроллер = одна единица техники в подписке.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Вектор — руководство по развёртыванию коробочной лицензии
|
||||
|
||||
## Системные требования
|
||||
|
||||
- **ОС:** Linux (Ubuntu 22.04 LTS / РЕД ОС 7.3)
|
||||
- **Процессор:** 4 ядра
|
||||
- **ОЗУ:** 8 ГБ
|
||||
- **Диск:** 80 ГБ SSD
|
||||
- **Сеть:** доступ к контроллерам оборудования по OPC UA (4840) и Modbus TCP (502)
|
||||
|
||||
## Порядок установки
|
||||
|
||||
1. Разверните дистрибутив из архива `vektor-server-<version>.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».
|
||||
|
||||
## Проверка
|
||||
|
||||
После подключения зайдите в «Живые данные» — метрики должны обновляться в течение секунды.
|
||||
Если данных нет, проверьте сетевую связность и брандмауэр.
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"notifications": [
|
||||
{
|
||||
"type": "DIALOG_WAITING",
|
||||
"level": "WARNING",
|
||||
"title": "Диалог ожидает оператора",
|
||||
"body": "Сергей Орлов ждёт ответа в канале продаж Вектора.",
|
||||
"targetRoute": "conversations",
|
||||
"targetId": "vektor-queue",
|
||||
"audience": "OPERATORS",
|
||||
"department": "sales",
|
||||
"sourceId": "vektor-queue",
|
||||
"readBy": "sales_lead"
|
||||
},
|
||||
{
|
||||
"type": "PAYMENT_RECEIVED",
|
||||
"level": "SUCCESS",
|
||||
"title": "Платёж получен",
|
||||
"body": "Оплачен заказ на подписку Репер для ТрансЛогистик.",
|
||||
"targetRoute": "orders",
|
||||
"targetId": "demo-ord-003",
|
||||
"audience": "OWNER",
|
||||
"sourceId": "demo-ord-003"
|
||||
},
|
||||
{
|
||||
"type": "DIALOG_NEW_MESSAGE",
|
||||
"level": "INFO",
|
||||
"title": "Новое сообщение в диалоге",
|
||||
"body": "Наталья Кузнецова написала в продажи Вектора.",
|
||||
"targetRoute": "conversations",
|
||||
"targetId": "vektor-lead-active",
|
||||
"audience": "OPERATORS",
|
||||
"department": "sales",
|
||||
"sourceId": "vektor-lead-active"
|
||||
},
|
||||
{
|
||||
"type": "INTEGRATION_ERROR",
|
||||
"level": "CRITICAL",
|
||||
"title": "Проблема с интеграцией",
|
||||
"body": "Подключение Репер MAX не проверено. Данные могут не доходить.",
|
||||
"audience": "OWNER",
|
||||
"sourceId": "reper-sales-max"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"daysElapsed": 15,
|
||||
"daysRemaining": 16,
|
||||
"counters": [
|
||||
{ "quota": "ai_agent_slots", "unit": "slots", "used": 3, "reserved": 0 },
|
||||
{ "quota": "new_dialogs_per_period", "unit": "dialogs", "used": 47, "reserved": 0 },
|
||||
{ "quota": "managed_ai_credits", "unit": "credits", "used": 1840, "reserved": 0 },
|
||||
{ "quota": "storage_bytes", "unit": "bytes", "used": 5242880, "reserved": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"organization": {
|
||||
"slug": "severnaya-verf",
|
||||
"name": "Северная Верфь",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"aiAgentSlots": 5,
|
||||
"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": "Елена Соколова",
|
||||
"role": "ADMIN",
|
||||
"positionTitle": "Руководитель клиентского сервиса",
|
||||
"phone": "+7 812 240 19 01",
|
||||
"isStaff": true
|
||||
},
|
||||
{
|
||||
"key": "sales_lead",
|
||||
"email": "d.morozov@severnayaverf.ru",
|
||||
"password": "Demo-Sales-2026",
|
||||
"fullName": "Дмитрий Морозов",
|
||||
"role": "EMPLOYEE",
|
||||
"positionTitle": "Старший менеджер по продажам",
|
||||
"phone": "+7 921 330 11 45",
|
||||
"department": "sales",
|
||||
"systemProfile": "Sales operator"
|
||||
},
|
||||
{
|
||||
"key": "sales_junior",
|
||||
"email": "k.belaya@severnayaverf.ru",
|
||||
"password": "Demo-Sales-2026",
|
||||
"fullName": "Кристина Белая",
|
||||
"role": "EMPLOYEE",
|
||||
"positionTitle": "Менеджер по продажам",
|
||||
"phone": "+7 921 778 02 19",
|
||||
"department": "sales",
|
||||
"systemProfile": "Sales operator"
|
||||
},
|
||||
{
|
||||
"key": "support_lead",
|
||||
"email": "a.novikov@severnayaverf.ru",
|
||||
"password": "Demo-Support-2026",
|
||||
"fullName": "Андрей Новиков",
|
||||
"role": "EMPLOYEE",
|
||||
"positionTitle": "Старший инженер поддержки",
|
||||
"phone": "+7 812 240 19 50",
|
||||
"department": "support",
|
||||
"systemProfile": "Support operator"
|
||||
},
|
||||
{
|
||||
"key": "support_engineer",
|
||||
"email": "m.zhukova@severnayaverf.ru",
|
||||
"password": "Demo-Support-2026",
|
||||
"fullName": "Марина Жукова",
|
||||
"role": "EMPLOYEE",
|
||||
"positionTitle": "Инженер технической поддержки",
|
||||
"phone": "+7 812 240 19 51",
|
||||
"department": "support",
|
||||
"systemProfile": "Support operator"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"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" }
|
||||
],
|
||||
"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
|
||||
}
|
||||
],
|
||||
"portal": {
|
||||
"slug": "reper",
|
||||
"name": "Репер — центр помощи",
|
||||
"locale": "ru",
|
||||
"status": "PUBLISHED",
|
||||
"widgetChannel": "reper-help-widget",
|
||||
"products": ["reper"],
|
||||
"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 }
|
||||
],
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Loaders демо-сида по доменам.
|
||||
|
||||
Каждый модуль экспортирует ``load(context, refs)`` — идемпотентно создаёт записи
|
||||
своего домена и пополняет :class:`DemoRefs` для последующих шагов.
|
||||
"""
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Каталог: продукты, офферы, цены, связи отделов, источники продаж."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.identity.demo_seed import manifest
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.products.models import (
|
||||
Offer,
|
||||
Price,
|
||||
Product,
|
||||
ProductDepartment,
|
||||
ProductStatus,
|
||||
)
|
||||
from hub_platform.sales.models import Environment, SalesSource, SalesSourceStatus, SalesSourceType
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("catalog")
|
||||
organization = refs.organization
|
||||
now = timezone.now()
|
||||
|
||||
for item in data["products"]:
|
||||
product, _ = Product.objects.get_or_create(
|
||||
organization=organization,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"status": ProductStatus.ACTIVE,
|
||||
"site_url": item.get("siteUrl", ""),
|
||||
},
|
||||
)
|
||||
refs.products[item["code"]] = product
|
||||
for department_code in item.get("departments", []):
|
||||
department = refs.departments.get(department_code)
|
||||
if department is not None:
|
||||
ProductDepartment.objects.get_or_create(product=product, department=department)
|
||||
|
||||
for item in data["offers"]:
|
||||
_ensure_offer(refs, item)
|
||||
|
||||
for item in data["prices"]:
|
||||
_ensure_price(refs, item, now)
|
||||
|
||||
for item in data.get("salesSources", []):
|
||||
_ensure_sales_source(refs, item)
|
||||
|
||||
|
||||
def _ensure_offer(refs: DemoRefs, item: dict) -> None:
|
||||
product = refs.products[item["product"]]
|
||||
primary_box = None
|
||||
if item.get("primaryBoxOffer"):
|
||||
primary_box = refs.offers[item["primaryBoxOffer"]]
|
||||
offer, _ = Offer.objects.get_or_create(
|
||||
product=product,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"description": item.get("description", ""),
|
||||
"fulfillment_type": item["fulfillmentType"],
|
||||
"payment_type": item["paymentType"],
|
||||
"primary_box_offer": primary_box,
|
||||
"fiscal_name": item.get("fiscalName", ""),
|
||||
"is_active": True,
|
||||
"ai_offerable": item.get("aiOfferable", False),
|
||||
},
|
||||
)
|
||||
refs.offers[item["key"]] = offer
|
||||
|
||||
|
||||
def _ensure_price(refs: DemoRefs, item: dict, now) -> None:
|
||||
offer = refs.offers[item["offer"]]
|
||||
valid_from = now
|
||||
price, _ = Price.objects.get_or_create(
|
||||
offer=offer,
|
||||
currency=item.get("currency", "RUB"),
|
||||
billing_period=item["billingPeriod"],
|
||||
version=1,
|
||||
defaults={
|
||||
"amount_minor": item["amountMinor"],
|
||||
"valid_from": valid_from,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
refs.prices[item["key"]] = price
|
||||
|
||||
|
||||
def _ensure_sales_source(refs: DemoRefs, item: dict) -> None:
|
||||
product = refs.products[item["product"]]
|
||||
source, _ = SalesSource.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
product=product,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"type": SalesSourceType.PRODUCT_API,
|
||||
"environment": item.get("environment", Environment.PRODUCTION),
|
||||
"status": item.get("status", SalesSourceStatus.ACTIVE),
|
||||
"credential_hint": item.get("credentialHint", ""),
|
||||
},
|
||||
)
|
||||
refs.sales_sources[item["key"]] = source
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Каналы, интеграции, AI-агенты, база знаний (со вложениями), учёт LLM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from hub_platform.ai.knowledge_types import KnowledgeVisibility
|
||||
from hub_platform.ai.models import (
|
||||
AIAgent,
|
||||
AIAgentStatus,
|
||||
CredentialMode,
|
||||
Knowledge,
|
||||
KnowledgeAttachment,
|
||||
LlmInvocation,
|
||||
LlmInvocationStatus,
|
||||
)
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.demo_seed import manifest
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationStatus
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
from hub_platform.tenancy.storage_quota import finalize_storage, reserve_storage
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("channels_ai")
|
||||
organization = refs.organization
|
||||
|
||||
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", {}),
|
||||
},
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def _ensure_channel(refs: DemoRefs, item: dict) -> None:
|
||||
channel, _ = Channel.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"department": refs.departments.get(item.get("department")),
|
||||
"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", ""),
|
||||
"credential_mode": item.get("credentialMode", CredentialMode.CUSTOAI),
|
||||
"persona": item.get("persona", ""),
|
||||
"tone": item.get("tone", ""),
|
||||
"instructions": item.get("instructions", ""),
|
||||
},
|
||||
)
|
||||
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(
|
||||
organization=organization,
|
||||
title=item["title"],
|
||||
defaults={
|
||||
"category": category,
|
||||
"description": item.get("description", ""),
|
||||
"content": item.get("content", ""),
|
||||
"visibility": item.get("visibility", KnowledgeVisibility.ORGANIZATION),
|
||||
},
|
||||
)
|
||||
refs.knowledge[item["key"]] = knowledge
|
||||
if created:
|
||||
for attachment_key in item.get("attachments", []):
|
||||
_attach_knowledge_file(context, refs, knowledge, attachment_key)
|
||||
|
||||
|
||||
def _attach_knowledge_file(
|
||||
context: TenantContext, refs: DemoRefs, knowledge: Knowledge, filename: str
|
||||
) -> None:
|
||||
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)
|
||||
suffix = filename.rsplit(".", 1)[-1].lower()
|
||||
content_type = "text/markdown" if suffix == "md" else "application/octet-stream"
|
||||
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),
|
||||
)
|
||||
attachment.file.save(filename, ContentFile(data), save=True)
|
||||
finalize_storage(context=context, idempotency_key=reservation_key, actual_bytes=len(data))
|
||||
|
||||
|
||||
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),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Коммерция: заказы, позиции, продажи (через application-сервис), атрибуция.
|
||||
|
||||
Продажи создаются через канонический ``record_product_sales_event`` (ADR-HUB-0025),
|
||||
а не прямой ORM-write в Sale — это соблюдает append-only контракт журнала.
|
||||
Заказы (Hub Order) создаются напрямую, как и в существующем сиде.
|
||||
"""
|
||||
|
||||
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.refs import DemoRefs
|
||||
from hub_platform.orders.models import FulfillmentStatus, Order, OrderItem, PaymentStatus
|
||||
from hub_platform.sales.models import (
|
||||
ActorType,
|
||||
AttributionToken,
|
||||
Environment,
|
||||
ExternalCustomerIdentity,
|
||||
)
|
||||
from hub_platform.sales.services import record_product_sales_event
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("commerce")
|
||||
now = timezone.now()
|
||||
|
||||
for item in data.get("orders", []):
|
||||
_ensure_order(refs, item, now)
|
||||
|
||||
for item in data.get("sales", []):
|
||||
_ensure_sale(context, refs, item)
|
||||
|
||||
for item in data.get("externalCustomers", []):
|
||||
_ensure_external_customer(refs, item)
|
||||
|
||||
for item in data.get("attributionTokens", []):
|
||||
_ensure_attribution_token(refs, item, now)
|
||||
|
||||
|
||||
def _ensure_order(refs: DemoRefs, item: dict, now) -> None:
|
||||
payment_status = item["paymentStatus"]
|
||||
order, created = Order.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
source="demo-seed",
|
||||
external_id=item["externalId"],
|
||||
defaults={
|
||||
"product": refs.products[item["product"]],
|
||||
"contact": refs.contacts[item["contact"]],
|
||||
"conversation": refs.conversations.get(item.get("conversation")),
|
||||
"channel": refs.channels[item["channel"]],
|
||||
"payment_status": payment_status,
|
||||
"fulfillment_status": item.get("fulfillmentStatus", FulfillmentStatus.NONE),
|
||||
"amount_minor": item["amountMinor"],
|
||||
"paid_at": now if payment_status == PaymentStatus.PAID else None,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
for line in item.get("items", []):
|
||||
OrderItem.objects.get_or_create(
|
||||
order=order,
|
||||
offer=refs.offers[line["offer"]],
|
||||
defaults={
|
||||
"price": refs.prices.get(line.get("price")),
|
||||
"title": line["title"],
|
||||
"quantity": line.get("quantity", 1),
|
||||
"amount_minor": line["amountMinor"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_sale(context: TenantContext, refs: DemoRefs, item: dict) -> None:
|
||||
source = refs.sales_sources[item["salesSource"]]
|
||||
occurred_at = timezone.now() - timedelta(days=item.get("daysAgo", 0))
|
||||
sale_block = {
|
||||
"external_sale_id": item["externalSaleId"],
|
||||
"amount_minor": item["amountMinor"],
|
||||
"refunded_amount_minor": item.get("refundedAmountMinor", 0),
|
||||
"currency": item.get("currency", "RUB"),
|
||||
"items": item.get("items", []),
|
||||
}
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"event_id": item["eventId"],
|
||||
"event_type": item["eventType"],
|
||||
"occurred_at": occurred_at.isoformat(),
|
||||
"sale": sale_block,
|
||||
"metadata": item.get("metadata", {}),
|
||||
}
|
||||
record_product_sales_event(context=context, source=source, payload=payload)
|
||||
|
||||
|
||||
def _ensure_external_customer(refs: DemoRefs, item: dict) -> None:
|
||||
ExternalCustomerIdentity.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
product=refs.products[item["product"]],
|
||||
external_customer_id=item["externalCustomerId"],
|
||||
defaults={
|
||||
"contact": refs.contacts[item["contact"]],
|
||||
"environment": item.get("environment", Environment.PRODUCTION),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_attribution_token(refs: DemoRefs, item: dict, now) -> None:
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
token = secrets.token_urlsafe(24)
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
AttributionToken.objects.get_or_create(
|
||||
token_hash=token_hash,
|
||||
defaults={
|
||||
"organization": refs.organization,
|
||||
"product": refs.products[item["product"]],
|
||||
"offer": refs.offers.get(item.get("offer")),
|
||||
"contact": refs.contacts[item["contact"]],
|
||||
"conversation": refs.conversations[item["conversation"]],
|
||||
"channel": refs.channels.get(item.get("channel")),
|
||||
"connection": refs.integrations.get(item.get("connection")),
|
||||
"actor_type": item.get("actorType", ActorType.AI_AGENT),
|
||||
"actor_id": item.get("actorId", ""),
|
||||
"expires_at": now + timedelta(days=item.get("ttlDays", 30)),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Диалоги: контакты, идентичности подключений, диалоги, сообщения, прочтения."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
ControlMode,
|
||||
Conversation,
|
||||
ConversationRead,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
)
|
||||
from hub_platform.identity.demo_seed import manifest
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("conversations")
|
||||
organization = refs.organization
|
||||
now = timezone.now()
|
||||
|
||||
for item in data["contacts"]:
|
||||
contact, _ = Contact.objects.update_or_create(
|
||||
organization=organization,
|
||||
name=item["name"],
|
||||
defaults={"phone": item.get("phone", ""), "avatar_url": item.get("avatarUrl", "")},
|
||||
)
|
||||
refs.contacts[item["key"]] = contact
|
||||
|
||||
for item in data["connectionIdentities"]:
|
||||
_ensure_identity(refs, item)
|
||||
|
||||
for item in data["conversations"]:
|
||||
_ensure_conversation(refs, item, now)
|
||||
|
||||
|
||||
def _ensure_identity(refs: DemoRefs, item: dict) -> None:
|
||||
connection = refs.integrations[item["connection"]]
|
||||
contact = refs.contacts[item["contact"]]
|
||||
identity, _ = ConnectionIdentity.objects.update_or_create(
|
||||
connection=connection,
|
||||
external_user_id=item["externalUserId"],
|
||||
defaults={
|
||||
"contact": contact,
|
||||
"display_name": item.get("displayName", ""),
|
||||
"username": item.get("username", ""),
|
||||
},
|
||||
)
|
||||
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
|
||||
|
||||
conversation, _ = Conversation.objects.get_or_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", {}),
|
||||
},
|
||||
)
|
||||
Conversation.objects.filter(pk=conversation.pk).update(
|
||||
last_activity_at=now - timedelta(minutes=item.get("lastActivityMinutesAgo", 0))
|
||||
)
|
||||
conversation.refresh_from_db()
|
||||
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)
|
||||
|
||||
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},
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Базовый слой: организация, отделы, пользователи, участия, доступ, аудит.
|
||||
|
||||
Использует существующие идемпотентные хелперы (``ensure_*``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from hub_platform.identity.access_defaults import ensure_system_assignment
|
||||
from hub_platform.identity.audit import record_audit_event
|
||||
from hub_platform.identity.demo_seed import manifest
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.identity.models import (
|
||||
Department,
|
||||
EmployeeRole,
|
||||
HumanUser,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
)
|
||||
from hub_platform.identity.system_departments import ensure_system_departments
|
||||
from hub_platform.subscriptions.default_subscription import ensure_default_subscription
|
||||
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
|
||||
|
||||
departments = ensure_system_departments(organization)
|
||||
refs.departments.update(departments)
|
||||
for item in data.get("extraDepartments", []):
|
||||
dept, _ = Department.objects.get_or_create(
|
||||
organization=organization,
|
||||
code=item["code"],
|
||||
defaults={"name": item["name"]},
|
||||
)
|
||||
refs.departments[item["code"]] = dept
|
||||
|
||||
ensure_uncategorized_category(organization)
|
||||
slots = data.get("aiAgentSlots", 5)
|
||||
refs.subscription = ensure_default_subscription(organization, quantity=slots)
|
||||
|
||||
_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
|
||||
owner_membership = None
|
||||
|
||||
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
|
||||
|
||||
role = item["role"]
|
||||
membership, _ = OrganizationMembership.objects.get_or_create(
|
||||
user=user,
|
||||
organization=organization,
|
||||
defaults={
|
||||
"role": role,
|
||||
"position_title": item.get("positionTitle", ""),
|
||||
"phone": item.get("phone", ""),
|
||||
"primary_department": refs.departments.get(item.get("department", "")),
|
||||
},
|
||||
)
|
||||
refs.memberships[item["key"]] = membership
|
||||
|
||||
if role == EmployeeRole.OWNER:
|
||||
owner_membership = membership
|
||||
elif item.get("systemProfile") and owner_membership is not None:
|
||||
ensure_system_assignment(
|
||||
employee=membership,
|
||||
assigned_by=owner_membership,
|
||||
department=refs.departments[item["department"]],
|
||||
profile_name=item["systemProfile"],
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Операционные данные: звонки, уведомления, период учёта использования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.calls.models import (
|
||||
CallConnectionType,
|
||||
CallEndedBy,
|
||||
CallMetric,
|
||||
CallParticipant,
|
||||
CallSession,
|
||||
CallStatus,
|
||||
ParticipantConnectionState,
|
||||
ParticipantSide,
|
||||
)
|
||||
from hub_platform.identity.demo_seed import manifest
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.notifications.models import (
|
||||
Notification,
|
||||
NotificationAudience,
|
||||
NotificationLevel,
|
||||
NotificationRead,
|
||||
)
|
||||
from hub_platform.subscriptions.models import (
|
||||
UsageCounter,
|
||||
UsageEntryKind,
|
||||
UsageLedgerEntry,
|
||||
UsagePeriod,
|
||||
UsagePeriodStatus,
|
||||
)
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("operations")
|
||||
_ensure_calls(refs, data.get("calls", []))
|
||||
_ensure_notifications(refs, data.get("notifications", []))
|
||||
_ensure_usage_period(refs, data.get("usage"))
|
||||
|
||||
|
||||
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,
|
||||
defaults={
|
||||
"organization": refs.organization,
|
||||
"initiated_by": initiator,
|
||||
"status": item.get("status", CallStatus.ENDED),
|
||||
"delivery_connection": refs.integrations.get(item.get("deliveryConnection")),
|
||||
},
|
||||
)
|
||||
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:
|
||||
metrics = item.get("metrics")
|
||||
if not metrics:
|
||||
return
|
||||
CallMetric.objects.update_or_create(
|
||||
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"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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={
|
||||
"department": refs.departments.get(item.get("department")),
|
||||
"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_usage_period(refs: DemoRefs, usage_data: dict | None) -> None:
|
||||
if not usage_data:
|
||||
return
|
||||
subscription = refs.subscription
|
||||
now = timezone.now()
|
||||
starts_at = now - timedelta(days=usage_data.get("daysElapsed", 15))
|
||||
ends_at = now + timedelta(days=usage_data.get("daysRemaining", 15))
|
||||
period, _ = UsagePeriod.objects.get_or_create(
|
||||
subscription=subscription,
|
||||
status=UsagePeriodStatus.OPEN,
|
||||
defaults={"starts_at": starts_at, "ends_at": ends_at},
|
||||
)
|
||||
from hub_platform.subscriptions.models import QuotaDefinition
|
||||
|
||||
for item in usage_data.get("counters", []):
|
||||
quota, _ = QuotaDefinition.objects.get_or_create(
|
||||
key=item["quota"],
|
||||
defaults={"name": item["quota"].replace("_", " "), "unit": item.get("unit", "")},
|
||||
)
|
||||
counter, _ = UsageCounter.objects.update_or_create(
|
||||
period=period,
|
||||
quota_definition=quota,
|
||||
defaults={"used_value": item.get("used", 0), "reserved_value": item.get("reserved", 0)},
|
||||
)
|
||||
UsageLedgerEntry.objects.get_or_create(
|
||||
period=period,
|
||||
quota_definition=quota,
|
||||
idempotency_key=f"demo-usage:{period.id}:{item['quota']}",
|
||||
defaults={
|
||||
"kind": UsageEntryKind.OPENING_BALANCE,
|
||||
"quantity": item.get("used", 0),
|
||||
"unit": item.get("unit", ""),
|
||||
"source": "demo-seed",
|
||||
"rule_version": "demo-seed",
|
||||
"occurred_at": starts_at,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Поддержка: контракты, снимки идентичности, портал, статьи и ревизии."""
|
||||
|
||||
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.refs import DemoRefs
|
||||
from hub_platform.support.models import (
|
||||
ContractStatus,
|
||||
ProductSupportContract,
|
||||
SupportIdentitySnapshot,
|
||||
)
|
||||
from hub_platform.support_portals.content_services import (
|
||||
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.statuses import ArticleStatus, PortalStatus
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
|
||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
||||
data = manifest.load("support")
|
||||
_ensure_contracts(refs, data.get("contracts", []))
|
||||
_ensure_snapshots(refs, data.get("identitySnapshots", []))
|
||||
_ensure_portal(context, refs, data.get("portal"))
|
||||
|
||||
|
||||
def _ensure_contracts(refs: DemoRefs, items: list[dict]) -> None:
|
||||
for item in items:
|
||||
contract, _ = ProductSupportContract.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"product": refs.products[item["product"]],
|
||||
"version": item["version"],
|
||||
"status": item.get("status", ContractStatus.ACTIVE),
|
||||
},
|
||||
)
|
||||
refs.support_contracts[item["key"]] = contract
|
||||
|
||||
|
||||
def _ensure_snapshots(refs: DemoRefs, items: list[dict]) -> None:
|
||||
now = timezone.now()
|
||||
for item in items:
|
||||
contract = refs.support_contracts[item["contract"]]
|
||||
snapshot, _ = SupportIdentitySnapshot.objects.get_or_create(
|
||||
organization=refs.organization,
|
||||
contract=contract,
|
||||
subject_key=item["subjectKey"],
|
||||
defaults={
|
||||
"product": contract.product,
|
||||
"contract_code": contract.code,
|
||||
"account_key": item.get("accountKey"),
|
||||
"display_name": item.get("displayName", ""),
|
||||
"display_email": item.get("displayEmail", ""),
|
||||
"payload_json": item.get("payload", {}),
|
||||
"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)),
|
||||
},
|
||||
)
|
||||
refs.identity_snapshots[item["key"]] = snapshot
|
||||
|
||||
|
||||
def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | None) -> None:
|
||||
if not portal_data:
|
||||
return
|
||||
organization = refs.organization
|
||||
support_dept = refs.departments["support"]
|
||||
|
||||
portal, portal_created = SupportPortal.objects.get_or_create(
|
||||
slug=portal_data["slug"],
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"department": support_dept,
|
||||
"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},
|
||||
)
|
||||
|
||||
categories: dict[str, PortalCategory] = {}
|
||||
for item in portal_data.get("categories", []):
|
||||
category = PortalCategory.objects.filter(portal=portal, slug=item["slug"]).first()
|
||||
if category is None:
|
||||
category = create_category(
|
||||
context=context,
|
||||
portal=portal,
|
||||
data={
|
||||
"slug": item["slug"],
|
||||
"name": item["name"],
|
||||
"description": item.get("description", ""),
|
||||
"sortOrder": item.get("sortOrder", 0),
|
||||
},
|
||||
)
|
||||
categories[item["slug"]] = category
|
||||
|
||||
for item in portal_data.get("articles", []):
|
||||
_ensure_article(context, portal, categories, item)
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Чтение и валидация демонстрационных манифестов из каталога ``data``.
|
||||
|
||||
Манифесты — обычный JSON в каталоге ``demo_seed/data/``, отдельный файл на домен.
|
||||
Редактируются вручную; версия схемы проверяется при загрузке. Медиа-вложения
|
||||
(логотип, файлы знаний) лежат в ``demo_seed/data/media/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||
MEDIA_DIR = DATA_DIR / "media"
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
#: Файлы манифестов по доменам (имя файла без расширения).
|
||||
MANIFEST_FILES: tuple[str, ...] = (
|
||||
"organization",
|
||||
"catalog",
|
||||
"channels_ai",
|
||||
"conversations",
|
||||
"commerce",
|
||||
"support",
|
||||
"operations",
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(ImproperlyConfigured):
|
||||
"""Нарушение схемы или целостности манифеста демо-данных."""
|
||||
|
||||
|
||||
@cache
|
||||
def load(name: str) -> dict | list:
|
||||
"""Возвращает разобранный JSON-манифест ``name`` с проверкой схемы.
|
||||
|
||||
Кэшируется на процесс: повторные вызовы во время одного прогона сида
|
||||
не перечитывают файл.
|
||||
"""
|
||||
path = DATA_DIR / f"{name}.json"
|
||||
if not path.is_file():
|
||||
raise ManifestError(f"Demo manifest not found: {path}")
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ManifestError(f"Demo manifest {path} is not valid JSON: {error}") from error
|
||||
if name == "organization":
|
||||
_validate_organization(payload)
|
||||
else:
|
||||
_validate_section(payload, name)
|
||||
return payload
|
||||
|
||||
|
||||
def media_path(filename: str) -> Path:
|
||||
"""Абсолютный путь к файлу-вложению из каталога ``data/media``."""
|
||||
path = MEDIA_DIR / filename
|
||||
if not path.is_file():
|
||||
raise ManifestError(f"Demo media file not found: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def media_text(filename: str) -> str:
|
||||
"""Текстовое содержимое вложения (для SVG/Markdown)."""
|
||||
return media_path(filename).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def _validate_section(payload: dict | list, name: str) -> None:
|
||||
if not isinstance(payload, dict) or payload.get("schemaVersion") != SCHEMA_VERSION:
|
||||
raise ManifestError(f"{name} manifest schemaVersion must be {SCHEMA_VERSION}")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Оркестратор демо-сида «Северная Верфь».
|
||||
|
||||
Создаёт полный, взаимосвязанный набор выдуманных данных одной организации,
|
||||
будто система уже работает. Запускается строго в порядке FK-зависимостей:
|
||||
foundation → catalog → channels/ai → conversations → commerce → support →
|
||||
operations. Идемпотентен на каждом шаге.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
catalog as catalog_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
channels_ai as channels_ai_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
commerce as commerce_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
conversations as conversations_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
foundation as foundation_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
operations as operations_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.loaders import (
|
||||
support as support_loader,
|
||||
)
|
||||
from hub_platform.identity.demo_seed.refs import DemoRefs
|
||||
from hub_platform.tenancy.context import TenantContext
|
||||
|
||||
__all__ = ["DemoRefs", "run_demo_seed"]
|
||||
|
||||
|
||||
def run_demo_seed(context: TenantContext, *, refs: DemoRefs | None = None) -> DemoRefs:
|
||||
"""Запускает все loaders в порядке зависимостей.
|
||||
|
||||
Предполагается вызов внутри ``tenant_atomic(context)``: tenant-контекст
|
||||
активирован, RLS и storage-guard работают.
|
||||
"""
|
||||
refs = refs or DemoRefs()
|
||||
|
||||
foundation_loader.load(context, refs)
|
||||
catalog_loader.load(context, refs)
|
||||
channels_ai_loader.load(context, refs)
|
||||
conversations_loader.load(context, refs)
|
||||
commerce_loader.load(context, refs)
|
||||
support_loader.load(context, refs)
|
||||
operations_loader.load(context, refs)
|
||||
return refs
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Разделяемое состояние между loaders демо-сида.
|
||||
|
||||
Вынесено в отдельный модуль, чтобы избежать циклического импорта: orchestrator
|
||||
импортирует loaders, а loaders импортируют ``DemoRefs``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoRefs:
|
||||
"""Ключи → объекты моделей, создаваемые loaders.
|
||||
|
||||
Каждый loader вешает на ``refs`` созданные объекты, чтобы последующие
|
||||
шаги ссылались на них по строковым ключам из манифестов (не по PK).
|
||||
"""
|
||||
|
||||
organization: object | None = None
|
||||
subscription: object | None = None
|
||||
departments: 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)
|
||||
offers: dict[str, object] = field(default_factory=dict)
|
||||
prices: dict[str, object] = field(default_factory=dict)
|
||||
sales_sources: dict[str, object] = field(default_factory=dict)
|
||||
integrations: dict[str, object] = field(default_factory=dict)
|
||||
channels: dict[str, object] = field(default_factory=dict)
|
||||
knowledge: 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)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Загружает полный набор демонстрационных данных организации «Северная Верфь».
|
||||
|
||||
Данные — выдуманные (РФ-наполнение), полностью покрывают значимые домены
|
||||
системы. Источник — редактируемые JSON-манифесты в
|
||||
``hub_platform/identity/demo_seed/data/``.
|
||||
|
||||
Идемпотентен: повторный запуск не дублирует и не затирает записи. По умолчанию
|
||||
работает в режиме dry-run (ничего не пишет); запуск с ``--apply`` применяет сид.
|
||||
Вне режима отладки требует явного ``--force`` (например, при установке облака).
|
||||
"""
|
||||
|
||||
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.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."
|
||||
|
||||
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).",
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
org_data = manifest.load("organization")["organization"]
|
||||
|
||||
if not options["apply"]:
|
||||
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."
|
||||
)
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,105 @@
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
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.notifications.models import Notification
|
||||
from hub_platform.orders.models import Order
|
||||
from hub_platform.products.models import Offer, Price, Product
|
||||
from hub_platform.sales.models import Sale, SaleEvent
|
||||
from hub_platform.support.models import ProductSupportContract, SupportIdentitySnapshot
|
||||
from hub_platform.support_portals.models import PortalArticle, SupportPortal
|
||||
from hub_platform.tenancy.context import TenantActorKind, TenantContext
|
||||
from hub_platform.tenancy.database import tenant_atomic
|
||||
|
||||
DEMO_SLUG = "severnaya-verf"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class SeedDemoTests(TestCase):
|
||||
"""Покрывает создание, идемпотентность и dry-run демо-сида «Северная Верфь»."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
# Организация создаётся командой; здесь нужен только shell для контекста.
|
||||
Organization.objects.create(name="Северная Верфь", slug=DEMO_SLUG)
|
||||
|
||||
def test_creates_full_dataset(self) -> None:
|
||||
refs = _run_seed()
|
||||
|
||||
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
|
||||
)
|
||||
# Продукты, офферы, цены.
|
||||
self.assertEqual(Product.objects.filter(organization=refs.organization).count(), 2)
|
||||
self.assertGreaterEqual(
|
||||
Offer.objects.filter(product__organization=refs.organization).count(), 3
|
||||
)
|
||||
self.assertGreaterEqual(Price.objects.count(), 3)
|
||||
# Каналы и 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(Order.objects.filter(organization=refs.organization).count(), 3)
|
||||
self.assertGreaterEqual(Sale.objects.count(), 1)
|
||||
self.assertGreaterEqual(SaleEvent.objects.count(), 1)
|
||||
# Поддержка: контракты, снимки, портал со статьями.
|
||||
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(),
|
||||
"orders": Order.objects.filter(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(),
|
||||
"orders": Order.objects.filter(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())
|
||||
+5
-31
@@ -119,51 +119,25 @@ services:
|
||||
- /app/apps/web-chat/node_modules
|
||||
|
||||
# Явный одноразовый импорт демонстрационных данных. Сервис существует только
|
||||
# в compose.dev.yaml, профилем не включается в обычный `up` и не попадает в
|
||||
# canonical production compose или backend image.
|
||||
local-seed:
|
||||
profiles: ["local-seed"]
|
||||
# в 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", "/opt/custocrm-local-seed/seed.py"]
|
||||
command: ["python", "manage.py", "seed_demo", "--apply"]
|
||||
working_dir: /app/apps/backend
|
||||
environment:
|
||||
CUS_DB_ROLE: migration
|
||||
CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
|
||||
CUSTOCRM_LOCAL_SEED: "1"
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./dev/local-seed:/opt/custocrm-local-seed:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Отдельный импорт демо-контента портала не создаёт пользователей и не
|
||||
# изменяет пароли, роли, сессии или другие учётные данные.
|
||||
support-portal-seed:
|
||||
profiles: ["support-portal-seed"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/backend/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "/opt/custocrm-local-seed/seed_support_portal.py"]
|
||||
working_dir: /app/apps/backend
|
||||
environment:
|
||||
CUS_DB_ROLE: migration
|
||||
CUS_DELIVERY_MODE: ${CUS_DELIVERY_MODE:-CLOUD}
|
||||
CUSTOCRM_LOCAL_SEED: "1"
|
||||
CUS_HELP_BASE_DOMAIN: localhost
|
||||
CUS_HELP_PUBLIC_SCHEME: http
|
||||
CUS_HELP_PUBLIC_PORT: ""
|
||||
volumes:
|
||||
- ./apps/backend:/app/apps/backend
|
||||
- ./dev/local-seed:/opt/custocrm-local-seed:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"organization": {
|
||||
"slug": "edevs",
|
||||
"name": "Edevs",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"accounts": {
|
||||
"owner": {
|
||||
"email": "owner@edevs.tech",
|
||||
"password": "Owner-Local-2026",
|
||||
"fullName": "Иван Петров",
|
||||
"positionTitle": "Владелец"
|
||||
},
|
||||
"operator": {
|
||||
"email": "a.kotova@edevs.tech",
|
||||
"password": "Operator-Local-2026",
|
||||
"fullName": "Анна Котова",
|
||||
"positionTitle": "Оператор отдела продаж",
|
||||
"phone": "+7 916 245 14 02"
|
||||
}
|
||||
},
|
||||
"departments": [
|
||||
{ "code": "sales", "name": "Продажи" },
|
||||
{ "code": "support", "name": "Поддержка" }
|
||||
],
|
||||
"products": [
|
||||
{ "code": "firepage", "name": "FirePage", "departments": ["sales", "support"] },
|
||||
{ "code": "foxray", "name": "FoxRay", "departments": ["sales", "support"] }
|
||||
],
|
||||
"channels": [
|
||||
{
|
||||
"code": "edevs-main",
|
||||
"name": "Edevs — главный сайт",
|
||||
"department": "sales",
|
||||
"product": null,
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": false,
|
||||
"allow_anonymous_sessions": true,
|
||||
"allow_self_reported_contact": true,
|
||||
"allow_sales_attribution": false,
|
||||
"allow_checkout_actions": false
|
||||
},
|
||||
"agent": { "name": "Edevs — главный сайт Agent", "model": "gpt-5-mini" }
|
||||
},
|
||||
{
|
||||
"code": "partners",
|
||||
"name": "Партнёрская линия",
|
||||
"department": null,
|
||||
"product": null,
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": false,
|
||||
"allow_anonymous_sessions": true,
|
||||
"allow_self_reported_contact": true,
|
||||
"allow_sales_attribution": false,
|
||||
"allow_checkout_actions": false
|
||||
},
|
||||
"agent": null
|
||||
},
|
||||
{
|
||||
"code": "firepage-support",
|
||||
"name": "FirePage — поддержка",
|
||||
"department": "support",
|
||||
"product": "firepage",
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": true,
|
||||
"allow_anonymous_sessions": false,
|
||||
"allow_self_reported_contact": false,
|
||||
"allow_sales_attribution": false,
|
||||
"allow_checkout_actions": false
|
||||
},
|
||||
"agent": { "name": "FirePage — поддержка Agent", "model": "gpt-5-mini" }
|
||||
},
|
||||
{
|
||||
"code": "firepage-sales",
|
||||
"name": "FirePage — продажи",
|
||||
"department": "sales",
|
||||
"product": "firepage",
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": false,
|
||||
"allow_anonymous_sessions": true,
|
||||
"allow_self_reported_contact": true,
|
||||
"allow_sales_attribution": true,
|
||||
"allow_checkout_actions": true
|
||||
},
|
||||
"agent": { "name": "FirePage: продажи", "model": "gpt-5-mini" }
|
||||
},
|
||||
{
|
||||
"code": "foxray-sales",
|
||||
"name": "FoxRay",
|
||||
"department": "sales",
|
||||
"product": "foxray",
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": false,
|
||||
"allow_anonymous_sessions": true,
|
||||
"allow_self_reported_contact": true,
|
||||
"allow_sales_attribution": true,
|
||||
"allow_checkout_actions": true
|
||||
},
|
||||
"agent": { "name": "FoxRay: продажи", "model": "gpt-5-mini" }
|
||||
},
|
||||
{
|
||||
"code": "foxray-support",
|
||||
"name": "FoxRay — поддержка",
|
||||
"department": "support",
|
||||
"product": "foxray",
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": true,
|
||||
"allow_anonymous_sessions": false,
|
||||
"allow_self_reported_contact": false,
|
||||
"allow_sales_attribution": false,
|
||||
"allow_checkout_actions": false
|
||||
},
|
||||
"agent": { "name": "FoxRay — поддержка Agent", "model": "gpt-5-mini" }
|
||||
},
|
||||
{
|
||||
"code": "foxray-webinars",
|
||||
"name": "FoxRay — вебинары",
|
||||
"department": "sales",
|
||||
"product": "foxray",
|
||||
"isActive": false,
|
||||
"policy": {
|
||||
"requires_authenticated_product_identity": false,
|
||||
"allow_anonymous_sessions": true,
|
||||
"allow_self_reported_contact": true,
|
||||
"allow_sales_attribution": true,
|
||||
"allow_checkout_actions": false
|
||||
},
|
||||
"agent": null
|
||||
}
|
||||
],
|
||||
"integrations": [
|
||||
{ "key": "edevs-max", "provider": "MAX", "name": "Edevs MAX", "channel": "edevs-main", "status": "OK" },
|
||||
{ "key": "edevs-tg", "provider": "TELEGRAM", "name": "Edevs Telegram", "channel": "edevs-main", "status": "OK" },
|
||||
{ "key": "edevs-web", "provider": "WEB", "name": "Edevs Web", "channel": "edevs-main", "status": "OK" },
|
||||
{ "key": "partners-tg", "provider": "TELEGRAM", "name": "Партнёрская линия Telegram", "channel": "partners", "status": "OK" },
|
||||
{ "key": "firepage-tg", "provider": "TELEGRAM", "name": "FirePage Telegram", "channel": "firepage-support", "status": "OK" },
|
||||
{ "key": "firepage-web", "provider": "WEB", "name": "FirePage Web", "channel": "firepage-sales", "status": "OK" },
|
||||
{ "key": "foxray-tg", "provider": "TELEGRAM", "name": "FoxRay Telegram", "channel": "foxray-sales", "status": "OK" },
|
||||
{ "key": "foxray-max", "provider": "MAX", "name": "FoxRay MAX", "channel": "foxray-sales", "status": "UNCHECKED" },
|
||||
{ "key": "foxray-email", "provider": "EMAIL", "name": "FoxRay Email", "channel": "foxray-sales", "status": "UNCHECKED" },
|
||||
{ "key": "foxray-support-web", "provider": "WEB", "name": "FoxRay Support Web", "channel": "foxray-support", "status": "OK" }
|
||||
],
|
||||
"contacts": [
|
||||
{ "key": "maria", "name": "Мария Соколова", "phone": "+7 999 123 45 67", "connection": "foxray-tg", "externalUserId": "local-maria" },
|
||||
{ "key": "alex", "name": "Алексей Воронов", "phone": "", "connection": "foxray-max", "externalUserId": "local-alex" },
|
||||
{ "key": "olga", "name": "Ольга Миронова", "phone": "+7 901 555 11 22", "connection": "firepage-tg", "externalUserId": "local-olga" }
|
||||
],
|
||||
"conversations": [
|
||||
{
|
||||
"key": "foxray-ai",
|
||||
"channel": "foxray-sales",
|
||||
"connection": "foxray-tg",
|
||||
"contact": "maria",
|
||||
"externalChatId": "local-foxray-ai",
|
||||
"controlMode": "AI",
|
||||
"expectedResponder": "AI",
|
||||
"lastActivityMinutesAgo": 4,
|
||||
"messages": [
|
||||
{ "externalId": "local-foxray-ai-1", "author": "CONTACT", "text": "Хочу узнать условия для FoxRay." },
|
||||
{ "externalId": "local-foxray-ai-2", "author": "AI", "text": "Подскажу подходящий вариант и отвечу на вопросы." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "foxray-queue",
|
||||
"channel": "foxray-sales",
|
||||
"connection": "foxray-max",
|
||||
"contact": "alex",
|
||||
"externalChatId": "local-foxray-queue",
|
||||
"controlMode": "PAUSED",
|
||||
"expectedResponder": "OPERATOR",
|
||||
"lastActivityMinutesAgo": 12,
|
||||
"messages": [
|
||||
{ "externalId": "local-foxray-queue-1", "author": "CONTACT", "text": "Нужна помощь с выбором тарифа." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "firepage-support",
|
||||
"channel": "firepage-support",
|
||||
"connection": "firepage-tg",
|
||||
"contact": "olga",
|
||||
"externalChatId": "local-firepage-support",
|
||||
"controlMode": "HUMAN",
|
||||
"expectedResponder": "OPERATOR",
|
||||
"lastActivityMinutesAgo": 8,
|
||||
"messages": [
|
||||
{ "externalId": "local-firepage-support-1", "author": "CONTACT", "text": "Не получается войти в кабинет." },
|
||||
{ "externalId": "local-firepage-support-2", "author": "OPERATOR", "text": "Проверяю доступ к вашему аккаунту." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"orders": [
|
||||
{ "externalId": "local-order-paid", "product": "foxray", "contact": "maria", "conversation": "foxray-ai", "paymentStatus": "PAID", "fulfillmentStatus": "DELIVERED", "amountMinor": 299000 },
|
||||
{ "externalId": "local-order-pending", "product": "foxray", "contact": "alex", "conversation": "foxray-queue", "paymentStatus": "PENDING", "fulfillmentStatus": "PENDING", "amountMinor": 99000 },
|
||||
{ "externalId": "local-order-failed", "product": "firepage", "contact": "olga", "conversation": "firepage-support", "paymentStatus": "PAID", "fulfillmentStatus": "FAILED", "amountMinor": 149000 }
|
||||
],
|
||||
"aiInvocations": [
|
||||
{ "channel": "foxray-sales", "product": "foxray", "purpose": "local-seed-sales", "model": "gpt-5-mini", "promptTokens": 4200, "completionTokens": 1100, "costMicros": 156000 },
|
||||
{ "channel": "firepage-support", "product": "firepage", "purpose": "local-seed-support", "model": "gpt-5-mini", "promptTokens": 2800, "completionTokens": 760, "costMicros": 98000 }
|
||||
]
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit, local-only CustoCRM demo data importer.
|
||||
|
||||
This file is mounted only by compose.dev.yaml and is intentionally outside the
|
||||
backend image. It must never be copied into or executed by a production setup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parent
|
||||
MANIFEST_PATH = SEED_DIR / "manifest.json"
|
||||
|
||||
|
||||
def require_local_environment() -> None:
|
||||
if os.environ.get("CUS_ENV") != "local" or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1":
|
||||
raise SystemExit("Local seed is available only through compose.dev.yaml with CUS_ENV=local.")
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
with MANIFEST_PATH.open(encoding="utf-8") as stream:
|
||||
manifest = json.load(stream)
|
||||
if manifest.get("schemaVersion") != 1:
|
||||
raise SystemExit("Unsupported local seed manifest version.")
|
||||
return manifest
|
||||
|
||||
|
||||
def run() -> None:
|
||||
require_local_environment()
|
||||
sys.path.insert(0, "/app/apps/backend")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
|
||||
from hub_platform.ai.models import AIAgent, AIAgentStatus, LlmInvocation
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.conversations.models import (
|
||||
ConnectionIdentity,
|
||||
Contact,
|
||||
Conversation,
|
||||
ControlMode,
|
||||
ExpectedResponder,
|
||||
LifecycleState,
|
||||
Message,
|
||||
MessageAuthor,
|
||||
)
|
||||
from hub_platform.identity.access_defaults import ensure_system_assignment
|
||||
from hub_platform.identity.models import Department, EmployeeRole, HumanUser, Organization, OrganizationMembership
|
||||
from hub_platform.integrations.models import Integration, IntegrationKind
|
||||
from hub_platform.orders.models import FulfillmentStatus, Order, PaymentStatus
|
||||
from hub_platform.products.models import Product, ProductDepartment
|
||||
from hub_platform.subscriptions.default_subscription import ensure_default_subscription
|
||||
|
||||
manifest = load_manifest()
|
||||
now = timezone.now()
|
||||
|
||||
with transaction.atomic():
|
||||
org_data = manifest["organization"]
|
||||
organization, _ = Organization.objects.get_or_create(
|
||||
slug=org_data["slug"],
|
||||
defaults={
|
||||
"name": org_data["name"],
|
||||
"timezone": org_data["timezone"],
|
||||
"currency": org_data["currency"],
|
||||
},
|
||||
)
|
||||
ensure_uncategorized_category(organization)
|
||||
ensure_default_subscription(organization)
|
||||
|
||||
departments = {}
|
||||
for item in manifest["departments"]:
|
||||
department, _ = Department.objects.get_or_create(
|
||||
organization=organization, code=item["code"], defaults={"name": item["name"]}
|
||||
)
|
||||
departments[item["code"]] = department
|
||||
|
||||
accounts = manifest["accounts"]
|
||||
owner_data = accounts["owner"]
|
||||
owner, owner_created = HumanUser.objects.get_or_create(
|
||||
email=HumanUser.objects.normalize_email(owner_data["email"]),
|
||||
defaults={"full_name": owner_data["fullName"], "is_staff": True, "is_superuser": True},
|
||||
)
|
||||
if owner_created:
|
||||
owner.set_password(owner_data["password"])
|
||||
owner.save(update_fields=["password"])
|
||||
owner_membership, _ = OrganizationMembership.objects.get_or_create(
|
||||
user=owner,
|
||||
organization=organization,
|
||||
defaults={"role": EmployeeRole.OWNER, "position_title": owner_data["positionTitle"]},
|
||||
)
|
||||
if owner_membership.role != EmployeeRole.OWNER:
|
||||
raise SystemExit(f"{owner.email} is not the owner of local organization {organization.slug}.")
|
||||
|
||||
operator_data = accounts["operator"]
|
||||
operator, operator_created = HumanUser.objects.get_or_create(
|
||||
email=HumanUser.objects.normalize_email(operator_data["email"]),
|
||||
defaults={"full_name": operator_data["fullName"]},
|
||||
)
|
||||
if operator_created:
|
||||
operator.set_password(operator_data["password"])
|
||||
operator.save(update_fields=["password"])
|
||||
operator_membership, _ = OrganizationMembership.objects.get_or_create(
|
||||
user=operator,
|
||||
organization=organization,
|
||||
defaults={
|
||||
"role": EmployeeRole.EMPLOYEE,
|
||||
"position_title": operator_data["positionTitle"],
|
||||
"phone": operator_data["phone"],
|
||||
"primary_department": departments["sales"],
|
||||
},
|
||||
)
|
||||
ensure_system_assignment(
|
||||
employee=operator_membership,
|
||||
assigned_by=owner_membership,
|
||||
department=departments["sales"],
|
||||
profile_name="Sales operator",
|
||||
)
|
||||
|
||||
products = {}
|
||||
for item in manifest["products"]:
|
||||
product, _ = Product.objects.get_or_create(
|
||||
organization=organization, code=item["code"], defaults={"name": item["name"]}
|
||||
)
|
||||
products[item["code"]] = product
|
||||
for department_code in item["departments"]:
|
||||
ProductDepartment.objects.get_or_create(product=product, department=departments[department_code])
|
||||
|
||||
channels = {}
|
||||
for item in manifest["channels"]:
|
||||
channel, _ = Channel.objects.get_or_create(
|
||||
organization=organization,
|
||||
code=item["code"],
|
||||
defaults={
|
||||
"name": item["name"],
|
||||
"department": departments.get(item["department"]),
|
||||
"product": products.get(item["product"]),
|
||||
"is_active": item.get("isActive", True),
|
||||
**item["policy"],
|
||||
},
|
||||
)
|
||||
channels[item["code"]] = channel
|
||||
agent_data = item.get("agent")
|
||||
if agent_data:
|
||||
AIAgent.objects.get_or_create(
|
||||
channel=channel,
|
||||
defaults={
|
||||
"name": agent_data["name"],
|
||||
"model": agent_data["model"],
|
||||
"status": AIAgentStatus.ACTIVE,
|
||||
},
|
||||
)
|
||||
|
||||
integrations = {}
|
||||
for item in manifest["integrations"]:
|
||||
integration, _ = Integration.objects.get_or_create(
|
||||
organization=organization,
|
||||
provider=item["provider"],
|
||||
name=item["name"],
|
||||
defaults={
|
||||
"kind": IntegrationKind.MESSENGER,
|
||||
"channel": channels[item["channel"]],
|
||||
"status": item["status"],
|
||||
},
|
||||
)
|
||||
integrations[item["key"]] = integration
|
||||
|
||||
contacts = {}
|
||||
for item in manifest["contacts"]:
|
||||
connection = integrations[item["connection"]]
|
||||
identity = ConnectionIdentity.objects.filter(
|
||||
connection=connection, external_user_id=item["externalUserId"]
|
||||
).select_related("contact").first()
|
||||
if identity is None:
|
||||
contact = Contact.objects.create(organization=organization, name=item["name"], phone=item["phone"])
|
||||
ConnectionIdentity.objects.create(
|
||||
organization=organization,
|
||||
contact=contact,
|
||||
connection=connection,
|
||||
external_user_id=item["externalUserId"],
|
||||
display_name=item["name"],
|
||||
)
|
||||
else:
|
||||
contact = identity.contact
|
||||
contacts[item["key"]] = contact
|
||||
|
||||
conversations = {}
|
||||
for item in manifest["conversations"]:
|
||||
conversation, _ = Conversation.objects.get_or_create(
|
||||
organization=organization,
|
||||
channel=channels[item["channel"]],
|
||||
external_chat_id=item["externalChatId"],
|
||||
defaults={
|
||||
"connection": integrations[item["connection"]],
|
||||
"contact": contacts[item["contact"]],
|
||||
"lifecycle": LifecycleState.OPEN,
|
||||
"control_mode": item["controlMode"],
|
||||
"expected_responder": item["expectedResponder"],
|
||||
"assigned_operator": operator if item["controlMode"] == ControlMode.HUMAN else None,
|
||||
"transport_meta": {"localSeedKey": item["key"]},
|
||||
},
|
||||
)
|
||||
Conversation.objects.filter(pk=conversation.pk).update(
|
||||
last_activity_at=now - timedelta(minutes=item["lastActivityMinutesAgo"])
|
||||
)
|
||||
conversation.refresh_from_db()
|
||||
conversations[item["key"]] = conversation
|
||||
for message in item["messages"]:
|
||||
Message.objects.get_or_create(
|
||||
conversation=conversation,
|
||||
external_id=message["externalId"],
|
||||
defaults={
|
||||
"author_type": message["author"],
|
||||
"author_user": operator if message["author"] == MessageAuthor.OPERATOR else None,
|
||||
"text": message["text"],
|
||||
},
|
||||
)
|
||||
|
||||
for item in manifest["orders"]:
|
||||
payment_status = item["paymentStatus"]
|
||||
Order.objects.get_or_create(
|
||||
organization=organization,
|
||||
source="local-seed",
|
||||
external_id=item["externalId"],
|
||||
defaults={
|
||||
"product": products[item["product"]],
|
||||
"contact": contacts[item["contact"]],
|
||||
"conversation": conversations[item["conversation"]],
|
||||
"channel": conversations[item["conversation"]].channel,
|
||||
"payment_status": payment_status,
|
||||
"fulfillment_status": item["fulfillmentStatus"],
|
||||
"amount_minor": item["amountMinor"],
|
||||
"paid_at": now if payment_status == PaymentStatus.PAID else None,
|
||||
},
|
||||
)
|
||||
|
||||
for item in manifest["aiInvocations"]:
|
||||
channel = channels[item["channel"]]
|
||||
LlmInvocation.objects.get_or_create(
|
||||
channel=channel,
|
||||
purpose=item["purpose"],
|
||||
operation="chat",
|
||||
model=item["model"],
|
||||
defaults={
|
||||
"product": products[item["product"]],
|
||||
"prompt_tokens": item["promptTokens"],
|
||||
"completion_tokens": item["completionTokens"],
|
||||
"total_tokens": item["promptTokens"] + item["completionTokens"],
|
||||
"cost_micros": item["costMicros"],
|
||||
},
|
||||
)
|
||||
|
||||
print(json.dumps({
|
||||
"organization": organization.slug,
|
||||
"organizationPublicId": str(organization.public_id),
|
||||
"owner": owner.email,
|
||||
"operator": operator.email,
|
||||
"channels": Channel.objects.filter(organization=organization).count(),
|
||||
"integrations": Integration.objects.filter(organization=organization).count(),
|
||||
"conversations": Conversation.objects.filter(organization=organization).count(),
|
||||
"orders": Order.objects.filter(organization=organization).count(),
|
||||
"idempotent": True,
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,265 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only, credential-safe demo importer for the FoxRay support portal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SEED_DIR = Path(__file__).resolve().parent
|
||||
MANIFEST_PATH = SEED_DIR / "support_portal.json"
|
||||
|
||||
|
||||
def require_local_environment() -> None:
|
||||
if (
|
||||
os.environ.get("CUS_ENV") != "local"
|
||||
or os.environ.get("CUSTOCRM_LOCAL_SEED") != "1"
|
||||
):
|
||||
raise SystemExit(
|
||||
"Support portal seed is available only through compose.dev.yaml "
|
||||
"with CUS_ENV=local."
|
||||
)
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
with MANIFEST_PATH.open(encoding="utf-8") as stream:
|
||||
manifest = json.load(stream)
|
||||
if manifest.get("schemaVersion") != 1:
|
||||
raise SystemExit("Unsupported support portal seed manifest version.")
|
||||
return manifest
|
||||
|
||||
|
||||
def update_fields(instance, values: dict) -> None:
|
||||
changed = []
|
||||
for field, value in values.items():
|
||||
if getattr(instance, field) != value:
|
||||
setattr(instance, field, value)
|
||||
changed.append(field)
|
||||
if changed:
|
||||
instance.full_clean()
|
||||
if any(field.name == "updated_at" for field in instance._meta.fields):
|
||||
changed.append("updated_at")
|
||||
instance.save(update_fields=changed)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
require_local_environment()
|
||||
sys.path.insert(0, "/app/apps/backend")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings")
|
||||
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from hub_platform.channels.models import Channel
|
||||
from hub_platform.identity.models import Department, Organization
|
||||
from hub_platform.integrations.models import (
|
||||
Integration,
|
||||
IntegrationKind,
|
||||
IntegrationProvider,
|
||||
IntegrationStatus,
|
||||
)
|
||||
from hub_platform.products.models import Product
|
||||
from hub_platform.support_portals.addressing import hosted_domain
|
||||
from hub_platform.support_portals.models import (
|
||||
PortalArticle,
|
||||
PortalArticleRevision,
|
||||
PortalCategory,
|
||||
SupportPortal,
|
||||
SupportPortalProduct,
|
||||
)
|
||||
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
|
||||
|
||||
manifest = load_manifest()
|
||||
portal_data = manifest["portal"]
|
||||
widget_data = manifest["widget"]
|
||||
now = timezone.now()
|
||||
|
||||
with transaction.atomic():
|
||||
organization = Organization.objects.get(slug=manifest["organization"])
|
||||
department = Department.objects.get(
|
||||
organization=organization,
|
||||
code="support",
|
||||
)
|
||||
product = Product.objects.get(
|
||||
organization=organization,
|
||||
code=portal_data["product"],
|
||||
)
|
||||
|
||||
channel, _ = Channel.objects.get_or_create(
|
||||
organization=organization,
|
||||
code=widget_data["channel"],
|
||||
defaults={
|
||||
"name": widget_data["channelName"],
|
||||
"department": department,
|
||||
"product": product,
|
||||
},
|
||||
)
|
||||
update_fields(
|
||||
channel,
|
||||
{
|
||||
"name": widget_data["channelName"],
|
||||
"department": department,
|
||||
"product": product,
|
||||
"is_active": True,
|
||||
"requires_authenticated_product_identity": False,
|
||||
"allow_anonymous_sessions": True,
|
||||
"allow_self_reported_contact": True,
|
||||
"allow_sales_attribution": False,
|
||||
"allow_checkout_actions": False,
|
||||
},
|
||||
)
|
||||
|
||||
integration, _ = Integration.objects.get_or_create(
|
||||
organization=organization,
|
||||
provider=IntegrationProvider.WEB,
|
||||
name=widget_data["integrationName"],
|
||||
defaults={"kind": IntegrationKind.MESSENGER},
|
||||
)
|
||||
update_fields(
|
||||
integration,
|
||||
{
|
||||
"kind": IntegrationKind.MESSENGER,
|
||||
"channel": channel,
|
||||
"config": widget_data["config"],
|
||||
"status": IntegrationStatus.OK,
|
||||
"is_active": True,
|
||||
"last_error": "",
|
||||
},
|
||||
)
|
||||
|
||||
portal, _ = SupportPortal.objects.get_or_create(
|
||||
slug=portal_data["slug"],
|
||||
defaults={
|
||||
"organization": organization,
|
||||
"department": department,
|
||||
"hosted_domain": hosted_domain(portal_data["slug"]),
|
||||
"name": portal_data["name"],
|
||||
"default_locale": portal_data["locale"],
|
||||
},
|
||||
)
|
||||
update_fields(
|
||||
portal,
|
||||
{
|
||||
"department": department,
|
||||
"hosted_domain": hosted_domain(portal_data["slug"]),
|
||||
"name": portal_data["name"],
|
||||
"default_locale": portal_data["locale"],
|
||||
"widget_channel": channel,
|
||||
"status": PortalStatus.PUBLISHED,
|
||||
"published_at": portal.published_at or now,
|
||||
},
|
||||
)
|
||||
|
||||
authenticated_channel = Channel.objects.filter(
|
||||
organization=organization,
|
||||
code=portal_data["authenticatedSupportChannel"],
|
||||
product=product,
|
||||
department=department,
|
||||
requires_authenticated_product_identity=True,
|
||||
allow_anonymous_sessions=False,
|
||||
).first()
|
||||
product_link, _ = SupportPortalProduct.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
product=product,
|
||||
defaults={"support_channel": authenticated_channel},
|
||||
)
|
||||
if product_link.support_channel_id != getattr(
|
||||
authenticated_channel,
|
||||
"id",
|
||||
None,
|
||||
):
|
||||
product_link.support_channel = authenticated_channel
|
||||
product_link.full_clean()
|
||||
product_link.save(update_fields=["support_channel"])
|
||||
|
||||
categories = {}
|
||||
for item in manifest["categories"]:
|
||||
category, _ = PortalCategory.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
slug=item["slug"],
|
||||
defaults={"name": item["name"]},
|
||||
)
|
||||
update_fields(
|
||||
category,
|
||||
{
|
||||
"name": item["name"],
|
||||
"description": item["description"],
|
||||
"sort_order": item["sortOrder"],
|
||||
},
|
||||
)
|
||||
categories[item["slug"]] = category
|
||||
|
||||
for item in manifest["articles"]:
|
||||
article, _ = PortalArticle.objects.get_or_create(
|
||||
organization=organization,
|
||||
portal=portal,
|
||||
locale=portal_data["locale"],
|
||||
slug=item["slug"],
|
||||
defaults={"category": categories[item["category"]]},
|
||||
)
|
||||
category = categories[item["category"]]
|
||||
latest = article.revisions.order_by("-revision").first()
|
||||
revision_content = (
|
||||
item["title"],
|
||||
item["summary"],
|
||||
item["content"],
|
||||
)
|
||||
if latest is None or (
|
||||
latest.title,
|
||||
latest.summary,
|
||||
latest.content,
|
||||
) != revision_content:
|
||||
latest = PortalArticleRevision.objects.create(
|
||||
organization=organization,
|
||||
article=article,
|
||||
revision=(latest.revision + 1) if latest else 1,
|
||||
title=item["title"],
|
||||
summary=item["summary"],
|
||||
content=item["content"],
|
||||
published_at=now,
|
||||
)
|
||||
elif latest.published_at is None:
|
||||
latest.published_at = now
|
||||
latest.save(update_fields=["published_at"])
|
||||
|
||||
article.category = category
|
||||
article.status = ArticleStatus.PUBLISHED
|
||||
article.published_revision = latest
|
||||
article.full_clean()
|
||||
article.save(
|
||||
update_fields=[
|
||||
"category",
|
||||
"status",
|
||||
"published_revision",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"organization": organization.slug,
|
||||
"portal": portal.slug,
|
||||
"url": f"http://{portal.hosted_domain}/",
|
||||
"widgetChannel": channel.code,
|
||||
"categories": len(categories),
|
||||
"articles": len(manifest["articles"]),
|
||||
"idempotent": True,
|
||||
"credentialsChanged": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"organization": "edevs",
|
||||
"portal": {
|
||||
"slug": "foxray",
|
||||
"name": "FoxRay",
|
||||
"locale": "ru",
|
||||
"product": "foxray",
|
||||
"authenticatedSupportChannel": "foxray-support"
|
||||
},
|
||||
"widget": {
|
||||
"channel": "foxray-help",
|
||||
"channelName": "FoxRay — чат поддержки",
|
||||
"integrationName": "FoxRay Help Center Web",
|
||||
"config": {
|
||||
"title": "Поддержка FoxRay",
|
||||
"accent": "#1677ff",
|
||||
"greeting": "Здравствуйте! Поможем разобраться с FoxRay.",
|
||||
"consent_text": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.",
|
||||
"consent_version": "v1",
|
||||
"quick_replies": [
|
||||
"Как начать работу",
|
||||
"Не могу войти",
|
||||
"Связаться со специалистом"
|
||||
],
|
||||
"allowed_domains": [
|
||||
"foxray.localhost",
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"slug": "getting-started",
|
||||
"name": "Начало работы",
|
||||
"description": "Первые шаги и основные возможности",
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"slug": "account-access",
|
||||
"name": "Аккаунт и доступ",
|
||||
"description": "Вход, восстановление доступа и уведомления",
|
||||
"sortOrder": 20
|
||||
},
|
||||
{
|
||||
"slug": "support",
|
||||
"name": "Обращения в поддержку",
|
||||
"description": "Создание и отслеживание обращений",
|
||||
"sortOrder": 30
|
||||
},
|
||||
{
|
||||
"slug": "billing",
|
||||
"name": "Оплата и документы",
|
||||
"description": "Счета и закрывающие документы",
|
||||
"sortOrder": 40
|
||||
}
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"slug": "first-login",
|
||||
"category": "getting-started",
|
||||
"title": "Первый вход в FoxRay",
|
||||
"summary": "Краткий маршрут после получения доступа.",
|
||||
"content": "# Первый вход в FoxRay\n\n1. Откройте адрес рабочего пространства, который прислал администратор.\n2. Введите рабочий email и пароль.\n3. Проверьте название организации в верхней части страницы.\n\n> Если приглашение или адрес не открывается, напишите в чат поддержки на этой странице.\n\n## Что проверить после входа\n\n- доступные разделы;\n- профиль и контактные данные;\n- настройки уведомлений."
|
||||
},
|
||||
{
|
||||
"slug": "restore-access",
|
||||
"category": "account-access",
|
||||
"title": "Как восстановить доступ",
|
||||
"summary": "Что проверить, если не получается войти.",
|
||||
"content": "# Как восстановить доступ\n\nСначала убедитесь, что используете рабочий email и правильный адрес организации.\n\n## Если войти не удалось\n\n- повторите ввод без пробелов в начале и конце;\n- проверьте раскладку и `Caps Lock`;\n- обратитесь к администратору вашей организации;\n- напишите в чат поддержки и укажите рабочий email.\n\nПароль в сообщении отправлять не нужно."
|
||||
},
|
||||
{
|
||||
"slug": "notifications",
|
||||
"category": "account-access",
|
||||
"title": "Настройка уведомлений",
|
||||
"summary": "Как выбрать важные события и не пропустить ответ.",
|
||||
"content": "# Настройка уведомлений\n\nОткройте настройки профиля и проверьте доступные способы уведомлений.\n\n| Событие | Рекомендация |\n| --- | --- |\n| Ответ специалиста | Оставить включённым |\n| Изменение статуса | Оставить включённым |\n| Информационные сообщения | Настроить по необходимости |\n\nИзменения применяются после сохранения формы."
|
||||
},
|
||||
{
|
||||
"slug": "create-support-request",
|
||||
"category": "support",
|
||||
"title": "Как обратиться в поддержку",
|
||||
"summary": "Какие данные помогут быстрее разобраться в вопросе.",
|
||||
"content": "# Как обратиться в поддержку\n\nНажмите синюю кнопку чата в правом нижнем углу портала.\n\nОпишите:\n\n- что вы хотели сделать;\n- что произошло вместо ожидаемого результата;\n- адрес страницы;\n- время возникновения проблемы.\n\nСекреты, пароли и платёжные данные отправлять не нужно."
|
||||
},
|
||||
{
|
||||
"slug": "track-request",
|
||||
"category": "support",
|
||||
"title": "Где посмотреть статус обращения",
|
||||
"summary": "Как продолжить уже начатый разговор.",
|
||||
"content": "# Где посмотреть статус обращения\n\nВернитесь на портал в том же браузере и снова откройте чат. Активная локальная сессия продолжится автоматически.\n\nСтатусы в чате показывают, кто сейчас готовит ответ: виртуальный помощник или специалист поддержки."
|
||||
},
|
||||
{
|
||||
"slug": "payment-documents",
|
||||
"category": "billing",
|
||||
"title": "Оплата и документы",
|
||||
"summary": "Куда обратиться по вопросам счетов и документов.",
|
||||
"content": "# Оплата и документы\n\nПо вопросу оплаты или закрывающих документов напишите в чат поддержки.\n\nУкажите:\n\n1. название организации;\n2. номер счёта, если он известен;\n3. период, за который нужны документы.\n\nНе прикладывайте данные банковской карты."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
$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
|
||||
@@ -1,6 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# This script always includes compose.dev.yaml. The importer has no production
|
||||
# service and refuses to run unless CUS_ENV=local is set in the container.
|
||||
docker compose -f compose.yaml -f compose.dev.yaml --profile local-seed --env-file .env.example --env-file .env `
|
||||
run --rm local-seed
|
||||
@@ -1,5 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Portal-only demo data: no users, passwords, roles, sessions, or credentials.
|
||||
docker compose -f compose.yaml -f compose.dev.yaml --profile support-portal-seed `
|
||||
--env-file .env.example --env-file .env run --rm support-portal-seed
|
||||
Reference in new issue
Block a user