Граф Wiki

Клиенты

Список клиентов и папки, карта подключений, лицевые счета и финансовые операции.

Клиенты — управление подключениями
Модуль «Биллинг-ядро» — на сайте продукта
Содержание раздела

8. Клиенты

Раздел меню «Клиенты» — основной рабочий раздел оператора.

Обучающее видео 2:50

Папка и глобальный поиск, счётчики-фильтры и цвет строки, одиннадцать массовых действий, создание клиента с учётной записью и IP, карточка клиента и карта.

1920×1080 с озвучкой Скачать

Папки клиентов — назначение и роль

Содержание раздела

Папка клиентов — это «контейнер-узел» в дереве клиентов с флагом IS_FOLDER=true. Хранится в той же таблице abonents (поле parent_id), но не имеет тарифа, счёта и финансовых атрибутов. Корневая папка имеет ID=9001.

Зачем нужны папки

Папки решают три задачи:

  1. Логическая группировка — клиенты раскладываются по агентам, филиалам, типам клиентов, географическим зонам. Это удобно для навигации и фильтрации в админке;
  2. Контроль доступа операторов — через AbonentGroupAccess Django-группа привязывается к конкретным папкам, и оператор видит только своих клиентов;
  3. Корзина удалённых — обычно это спец-папка (#9003 «Удалённые»), куда перемещаются deleted=true-клиенты для возможного восстановления.
Как устроено дерево

Иерархия держится на parent_id — FK таблицы на саму себя:

Поля lft / rght / tree_id / level не используйте. У новых записей они не заполняются: модель Abonents не подключена к MPTT-менеджеру, а значит rebuild() и get_descendants() недоступны. Код, который читает эти поля, обязан иметь fallback на parent_id — как это сделано в выборке клиентов по папкам (views/messages_global.py). Обход поддерева пишется обходом по уровням через parent_id.
Группы и контроль доступа

Связь «Группа Django ↔ Папка клиента» хранится в AbonentGroupAccess:

Папка организации

При включённой мультиорганизации папку верхнего уровня (лежащую внутри корневой «Все») можно закрепить за компанией — она становится её «домом»: туда складываются новые клиенты этой организации, и по ней строятся списки директорий в карточках тарифов.

Дерево папок: цвет иконки — акцентный цвет организации

Смена организации у папки — операция с данными, а не пометка. Она переписывает организацию у всего содержимого, включая финоперации клиентов. Отменить одним действием нельзя: обратный перенос — это повторное закрепление за прежней компанией.
Что делать со старыми папками
Важно про корзину: при Abonents.delete() в коде выполняется soft-delete: deleted=true + enabled=false, клиент остаётся на своём месте в дереве. Если хотите централизованную «Корзину» — настройте автоматическое перемещение в #9003 через bulk action или добавьте логику в delete() модели.

8.1. Клиенты

Главный список клиентов. Можно работать в двух режимах: дерево папок (по умолчанию) и глобальный поиск. Папки удобны для иерархической навигации по агентам/филиалам/адресам, глобальный поиск — для массовых операций над всей базой.

Папка клиентов

Папка клиентов (/admin/Abonents/<id>/, корень — /admin/Abonents/9001/) — список клиентов конкретной папки с поддержкой подпапок. Каждая папка имеет свой набор быстрых фильтров и массовых действий, рассчитанный на ежедневную работу оператора с одним сегментом базы.

Папка клиентов

Содержание раздела
KPI и статистика

Над таблицей выводятся 4–5 счётчиков по клиентам текущей папки (без учёта подпапок): Активных, Сейчас онлайн, Выключенных, Всего, Юр. лиц. Числа обновляются вместе с применённым фильтром-чипом.

Чипы быстрых фильтров

Под счётчиками — ряд чипов с готовыми фильтрами. Активен может быть только один. Повторный клик снимает фильтр. Состояние сохраняется в URL — ссылку можно отправить коллеге:

Колонки таблицы
  1. ☑ — чекбокс выбора для массовых операций.
  2. — внутренний ID клиента.
  3. ФИО — клик открывает карточку.
  4. Дог. — номер договора.
  5. Тариф — с иконкой 💼 для бизнес-тарифов.
  6. Адрес — короткий формат (через фильтр home_short: ВО → Волгоградская обл., Влг → Волгоград).
  7. Контакты — иконки ✉ / 📞 с tooltip-значениями. Серая иконка = пустое поле.
  8. Баланс — баланс счёта. Рядом 🤝 при активном обещанном платеже.
  9. Подкл. — дата подключения.
  10. 📡 — флаг «онлайн сейчас» (RADIUS-сессия < 15 мин).
  11. Статус — цветной бейдж: Активен / Заблок. / Удалён.
  12. ℹ Подробнее — кнопка открывает drawer-панель без перехода на полную карточку.

Подсветка строк (класс строки задаётся в abonents_folder_rows.html):

Тумблер «Цвет маркер» над таблицей гасит подсветку целиком; выбор запоминается за оператором.

Бесконечная подгрузка

Папка не использует пагинацию. При прокрутке вниз автоматически догружается следующая порция (по 100 записей) через AJAX-endpoint /admin/Abonents/<id>/children_json/. На больших папках (1000+ клиентов) загрузка остаётся плавной — браузер рендерит только видимый кусок.

Массовые действия и экспорт

Чекбокс в первой колонке + «Выбрать все на странице» в шапке. После выделения появляется меню «Действия (N)» — те же 11 операций, что и в глобальном поиске: включить / выключить / удалить / изменить тариф / точку подключения / перенести в другую папку / добавить услугу / бонус / финоперацию / лояльность / отправить сообщение. Destructive-операции защищены confirm-модалкой с предпросмотром имён, для большинства — кнопка «Откатить» в баннере на 1 час.

Экспорт CSV — кнопка «📥 CSV» в шапке выгружает текущий срез папки (/admin/Abonents/<id>/export/): UTF-8 BOM, разделитель «;» для Excel RU. Колонки совпадают с видимыми в таблице плюс расшифровка тарифа, оператора, баланс в рублях. Учитываются текущий фильтр-чип и текстовый поиск.

Навигация по подпапкам

Если у текущей папки есть подпапки — они выводятся отдельным блоком над таблицей клиентов: карточки с именем подпапки и счётчиком клиентов внутри (включая глубоких потомков). Клик переходит в подпапку (отдельный URL). Хлебные крошки в шапке страницы показывают полный путь от корня (#9001 → город → улица → дом), кликабельны.

Мультиорганизация

Выбор организации в шапке — это фильтр отображения. Пока выбрана конкретная компания, раздел показывает только её данные:

Фильтр в шапке не даёт и не отнимает прав. Право на объект определяют организации сотрудника (изоляция персонала), а не текущий выбор в селекторе. Проверка доступа по прямой ссылке смотрит только на права: иначе при выбранной компании любой объект другой отдавал бы 404 — включая корневую папку клиентов, единственный вход в раздел. Ответ на действительно чужой объект — 404, одинаковый с «не существует», чтобы перебором нельзя было выяснить состав базы.

Модалка «Новый клиент»

Открывается зелёной кнопкой над деревом. Что важно знать при мульти-орг:

Модалка «Новый клиент» — выбор организации в шапке

Раскладка новых клиентов

Куда попадёт клиент при создании, задаёт настройка Настройки → Система → Профиль → «Раскладка новых клиентов» (ABONENT_FOLDER_ROUTING):

РежимПоведение
По директории тарифа, иначе по организации значение по умолчанию: сначала папка тарифа, при её отсутствии — папка организации
Только по папке организации директория тарифа игнорируется
Не раскладывать клиент остаётся там, где его создают

Настройки → Система → Профиль → Раскладка новых клиентов

flowchart TD
    ADD["Создание клиента"] --> WHERE{"Откуда добавляют"}
    WHERE -->|"из конкретной папки"| KEEP["Остаётся в ней"]
    WHERE -->|"из корневой Все"| MODE{"Режим раскладки"}
    MODE -->|"Не раскладывать"| ROOT["Остаётся в корне"]
    MODE -->|"Только организация"| ORG["Папка организации"]
    MODE -->|"По тарифу"| HAS{"У тарифа есть директория"}
    HAS -->|"да"| TAR["Папка тарифа"]
    HAS -->|"нет"| ORG
Выбор оператора не переопределяется. Раскладка работает только при создании из корневой папки «Все». Если оператор открыл конкретную папку и добавляет клиента там — это осознанное решение, и запись остаётся на месте в любом режиме.

Если папка тарифа принадлежит другой компании, организация клиента синхронизируется по папке: иначе он лежал бы в чужой папке со своей меткой и пропадал из неё при фильтре по организации. На практике случай редкий — тарифы уже отфильтрованы по организации, и чужой выбрать нельзя.

Глобальный поиск (Клиенты → 🔍 Глобальный поиск, URL /admin/Abonents/search/) — плоский список всех клиентов без иерархии папок, с расширенными фильтрами и массовыми операциями. Папки в выборку не попадают (is_folder=false).

Раздел рассчитан на сценарии, когда нужно работать со всей базой сразу: найти клиента по обрывку информации (часть телефона, дома, фамилии), сделать массовую рассылку по тарифу, выгрузить срез для бухгалтерии.

Глобальный поиск клиентов

Содержание раздела
KPI-бейджи в шапке

Над таблицей выводится 4 счётчика по результатам текущей выборки: Активных (enabled=true) / Сейчас онлайн (RADIUS-сессия < 15 мин) / Выкл (enabled=false) / Всего. Цифры пересчитываются на каждой смене фильтра.

Чипы быстрых фильтров (7 штук)

Чипы взаимоисключающие — активен может быть только один. Кликом по уже активному чипу фильтр сбрасывается. Состояние записывается в URL (?chip=debtors) — ссылку можно отправить коллеге.

ЧипИконкаЧто показываетSQL-условие
Должники Клиенты с отрицательным балансом счёта admin_account.ostatok < 0
Бизнес💼 Юридические лица abonents.company = true
Без email Не указан e-mail для уведомлений email IS NULL OR email = ''
Без телефона📵 Не указан номер телефона (SMS не уйдут) sms IS NULL OR sms = ''
Онлайн📡 Сейчас в RADIUS-сессии (< 15 минут с last update) USERS_RADIUSAUTH.LOGGED = 1 AND RADIUS_UPDATE >= now() - 15 min
Заблокированные🚫 Любая активная блокировка (admin / negbal / own) есть запись в ABONENTS_BLOCK с активным флагом
Обещ. платёж🤝 Активен обещанный платёж (Usluga 30353) users_usluga.usluga_id = 30353 AND enabled = true
Multi-select фильтры
Текстовый поиск

Поле в шапке таблицы. Серверный поиск по: ФИО, номер договора, e-mail, телефон, адрес (город / улица / дом / квартира). Минимум 2 символа, debounce 300 мс. Шорткат Ctrl+K ставит фокус в поле поиска из любого места страницы.

Колонки таблицы (12)
  1. ☑ — чекбокс выбора для bulk-операций.
  2. — внутренний ID клиента.
  3. ФИО — имя, кликабельно (открывает карточку).
  4. Дог. — номер договора.
  5. Тариф — название текущего тарифа; для бизнес-тарифов иконка 💼.
  6. Адрес — короткий формат через фильтр home_short (ВО → Волгоградская обл., Влг → Волгоград, район сокращённо).
  7. Точка подкл. — название ConnectionPoint.
  8. Контакты — иконки ✉ / 📞 с tooltip-значениями. Серая иконка = не заполнено.
  9. Баланс — баланс счёта; рядом 🤝 при активном ОП.
  10. Подкл. — дата подключения.
  11. 📡 — флаг «онлайн сейчас» (RADIUS-сессия < 15 мин).
  12. Статус — цветной бейдж (Активен / Заблок. / Удалён).

Подсветка строк — та же, что в папке: розовый фон — блокировка за неуплату, жёлтый — другая блокировка, красный — выключен, усиленный красноватый — долг более 500 ₽. Разбор классов — в описании папки.

Бесконечный скролл

Глобальный поиск не использует пагинацию: при прокрутке вниз подгружается следующая порция результатов. Папочные страницы (/admin/Abonents/<id>/) работают по тому же принципу через AJAX-endpoint /admin/Abonents/<id>/children_json/ по 100 записей за запрос.

Массовые действия — 11 операций

Выбираете клиентов галочками в первой колонке (или «Выбрать все» в шапке) — в шапке появляется выпадающее меню «Действия (N)» с дюжиной операций. Endpoint: POST /admin/Abonents/bulk_action/ (payload: action + ids[] + параметры).

ДействиеЧто делаетUndo
Включить (enable) Снимает флаг enabled=false, шлёт CoA-Disconnect для переподключения.
Выключить (disable) Ставит enabled=false, разрывает RADIUS-сессии.
Удалить (delete) Soft-delete: deleted=true, все Users отключаются, CoA-Disconnect.
Изменить тариф (change_tarif) Смена тарифа (Select2 в модалке). Срабатывает сразу, не «с 1-го числа».
Изменить точку подключения (change_cp) Перевешивает клиента на другую ConnectionPoint.
Перенести в папку (move_folder) Меняет parent_id в MPTT-дереве (Select2 по папкам).
Добавить услугу (add_service) Подключает разовую/периодическую услугу (Usluga из Select2).
Добавить бонус (add_bonus) Подключает бонус (компенсация / скидка с отрицательной суммой).
Добавить операцию (add_finop) Создаёт FinanceOperations на счёт (тип, сумма, описание).
Добавить лояльность (add_loyalty) Включает программу лояльности (AbonentsLoyalty).
Отправить сообщение (send_msg) Рассылка по выбранным каналам: e-mail / SMS / Push / Telegram.

Для destructive-операций (Включить / Выключить / Удалить / Изменить тариф / Перенести в папку) после выполнения в шапке появляется баннер «Откатить (доступно N мин)» с таймером 1 час. Клик отменяет изменение для всех затронутых клиентов через POST /admin/Abonents/bulk_undo/. Undo-токен хранится в sessionStorage браузера. Create-операции (Добавить услугу / бонус / финоперацию / лояльность / отправить сообщение) не откатываются — придётся вручную удалять созданные записи.

Подтверждение опасных операций

Удаление, отключение, включение и смена тарифа открывают модальное окно подтверждения: показывает превью первых 5 имён выбранных клиентов, общее их количество и требует ввести точное число выбранных записей для активации кнопки «Подтвердить». Это защита от случайной массовой операции по невнимательности.

CSV-экспорт

Кнопка «📥 CSV» в шапке выгружает текущий видимый срез (/admin/Abonents/search/export/): UTF-8 BOM, разделитель «;» для Excel RU. Колонки те же что в таблице плюс расшифровка тарифа, оператор и баланс в рублях.

Слайд-панель клиента (drawer)

В строках таблиц списков клиентов (папка, глобальный поиск, должники) у каждой записи справа есть кнопка ℹ Подробнее (на mobile — клик по имени). Клик открывает выезжающую справа панель (slide-panel / drawer) с краткой сводкой по клиенту — без ухода со списка и потери контекста (фильтры, прокрутка, выделение сохраняются).

Панель решает задачу «быстро глянуть и сделать», когда полная карточка с десятком вкладок избыточна. Типичные сценарии за 5–30 секунд: проверить баланс и активировать ОП, посмотреть статус сессии при жалобе на интернет, отправить SMS-напоминание, добавить комментарий-заметку для коллег.

Слайд-панель клиента — общий вид при открытии

Содержание раздела
Где доступна панель

Программный API из любой страницы: window.AbDrawer.open(abonentId).

Шапка с плашками статуса

Вверху панели — компактные плашки с ключевыми атрибутами клиента (видно с одного взгляда, без скролла):

ПлашкаЧто показываетЦвет
#<номер договора>Номер договора клиента
Активен / ОтключёнСостояние клиента целикомзелёный / жёлтый
Физ. лицо / БизнесТип клиентасерый
🔑 ЛогинОсновной логин учётной записи (скрыт на mobile)серый
🌐 IPIP-адрес учётной записисерый
🔓 Вкл. / 🔒 Выкл.Состояние учётной записи (отдельно от клиента)зелёный / жёлтый
📶 Онлайн / 🔌 ОфлайнСейчас в RADIUS-сессии или нетзелёный / жёлтый
🪪 СОРМ N/9Полнота персональных данных по СОРМ-требованиямзелёный (9/9) / жёлтый (5–8) / красный (0–4)

Шапка имеет фон заметно темнее тела (light #e8edf2 / dark #14171b) — оператор сразу различает «где статус, где данные».

Финансовый KPI-блок

Сразу под шапкой — KPI-сетка 2×2 с самыми важными цифрами:

Если активен обещанный платёж — отдельная плашка с суммой и датой окончания под KPI-сеткой.

Аккордеон разделов и smart-defaults

Разделы панели объединены в аккордеон — оператор сам открывает что нужно, не скроллит мимо. Состояние свёрнутости запоминается в localStorage отдельно по каждой секции — выбор оператора сохраняется.

РазделSmart-default (открыт если…)
👤 Учётные записиУчётка отключена (!primary_enabled) — оператор сразу видит проблему
🌐 Последняя сессияКлиент онлайн или есть активная сессия
📈 Платежи за 12 мес.Всегда свёрнут — открывается по запросу
📋 Последние операцииДолжник (balance < 0) — оператор смотрит откуда долг
🏛 БанкРаздел показывается только при наличии хотя бы одного платежа из банковских выписок; открывается смотря по контексту вызова
📝 Комментарии операторовУже есть комментарии — значит история важна
🎧 ПоддержкаКлиент ждёт ответа в HelpDesk (waiting_for_us > 0)

Раздел «Банк» показывает последние платежи клиента из банковских выписок (банк, дата, сумма, плательщик). При открытии панели кликом по имени клиента в очереди чеков («Очередь чеков») панель автоматически прокручивается к разделу «Банк» и раскрывает его — оператор сразу видит, откуда пришёл платёж по разбираемой операции.

Слайд-панель клиента — раздел «Банк» раскрыт, показан платёж из выписки Сбербанка

Сброс сессии из блока «Последняя сессия»

Если у клиента есть активная RADIUS-сессия, в блоке «Последняя сессия» рядом с бейджем «Активная сессия» появляется кнопка «🔄 Сброс сессии». Она нужна при зависшей сессии (NAS не прислал Accounting-Stop, клиент «висит онлайн», но интернета нет).

Слайд-панель — блок «Последняя сессия» с кнопкой «Сброс сессии» и модалкой подтверждения

Если оператор однажды раскрыл/свернул секцию вручную — это его выбор сохраняется в localStorage и smart-defaults больше не перетирают.

Слайд-панель — все разделы аккордеона раскрыты

Раздел «Поддержка» (HelpDesk)

Шестой раздел аккордеона — интеграция с HelpDesk. Биллинг показывает все обращения клиента в техподдержку прямо в слайд-панели, без перехода в HelpDesk:

Слайд-панель — раздел Поддержка с тикетами HelpDesk

Форма отправки сообщений и табы каналов прилеплены ко дну панели (не уезжают при скролле истории сообщений). Оператор не скроллит чтобы написать SMS — форма всегда на виду, как в современных мессенджерах.

Sticky footer — форма отправки прилеплена ко дну при скролле

Каналы отправки (SMS / Email / Telegram / ЛК / ВК / Push)

Над формой ввода — chip-табы с каналами отправки. Показываются только доступные клиенту каналы:

При выборе таба Email над формой появляется поле «Тема письма» — если оператор не укажет, дефолтная тема возьмётся из первой строки текста (до 80 символов).

Табы каналов — выбран Email, появилось поле Тема письма

SMS-форма поддерживает дефолтный шаблон: при пустом тексте у должника автоматически отправляется напоминание «Договор XXX. Задолженность YYY ₽. После пополнения счёта доступ к услугам восстановится. Узнать баланс или взять ОП — отправьте «Баланс» или «Оп» в ответ на это сообщение. ЛК: lk.вашдомен.ru».

Для клиентов с положительным балансом дефолтный шаблон не отправляется — backend возвращает ошибку no_text_positive_balance, оператор должен ввести текст вручную.

Email в фирменном брендинге

Email-сообщения отправляются HTML в фирменном стиле — не plain text, а полноценное письмо с шаблоном emails/generic.html:

Авто-детект шаблона по теме/тексту: если содержит «обещанн», «оплат», «блокир», «возобновл», «баланс+низк» — выбирается специализированный пресет (promise_pay/payment_received/blocked/ unblocked/low_balance), иначе generic.

Доступность (WCAG colour independence)

Все цветовые статусы дублируются иконками — пользователи с deuteranopia (8% мужчин) различают информацию без полагания на цвет:

Mobile-режим

На телефоне (≤768px) панель раскрывается на весь экран (width: 100vw, height: 100dvh, без скруглений и отступа сверху) — не узкая шторка сбоку и не нижний лист, а полноэкранная слайд-панель, как модалки в мобильной версии проекта. Поведение одинаковое с desktop, только:

Слайд-панель на mobile — fullscreen с sticky footer

Тёмная тема

Drawer полностью адаптирован под тёмную тему. Header ещё темнее тела (#14171b vs body #1f2227), все секции и формы имеют тёмные варианты, footer-actions с своим bg.

Слайд-панель в тёмной теме

Карточка клиента

Переход кликом по строке клиента — открывается отдельная страница с вкладками Информация, История, Тариф и услуги, Бонусы, Финансы, Точки подключения, Учётные записи, RADIUS, СОРМ, Реквизиты, HelpDesk, Лояльность. Подробное описание каждой вкладки — в отдельном разделе 9. Карточка клиента.

Bulk-операции и Undo

Выберите клиентов галочками — появится плавающая панель действий внизу экрана.

Bulk-операции с клиентами

Доступные действия:

Для destructive-операций (delete, disable, enable, change_tarif, move_folder) после выполнения в шапке появляется баннер «Откатить (доступно N мин)» с таймером 1 час. Клик отменяет изменение для всех затронутых клиентов.

8.2. На карте

Карта клиентов в браузере (Leaflet + OSM / 2ГИС / Yandex). Каждый маркер — клиент; отдельно показываются NAS и слой заявок Поддержки. URL: /admin/abonents/map/. Меню сайдбара: Клиенты → 🗺 На карте.

Карта клиентов — общий вид со слоями и легендой

- "На карте"
  - Слои
    - Клиенты
      - Кластеры по плотности
      - Цвет = статус
    - NAS
      - Покрытие
      - Онлайн-панель
    - Заявки Поддержки
  - Фильтры
    - Статусы
    - Услуги
    - Поиск Ctrl+K
  - Данные
    - Только точные координаты
    - Скоуп по организации
    - Кеш 5 минут
  - Действия
    - Попап клиента
    - Карточка заявки
    - Правка координат

Возможности

Popup клиента: NAS-ссылка, org-лейбл, услуги

Откуда берутся точки

Карта не запрашивает данные при каждом движении — весь набор приходит одним JSON и живёт в памяти вкладки. Отсюда два следствия: панорамирование мгновенное, но состав точек фиксируется в момент загрузки.

flowchart LR
    REQ["Открытие карты"] --> CACHE{"Кеш 5 минут
по организации"} CACHE -->|"есть"| JSON["JSON одним ответом"] CACHE -->|"нет"| HOMES["Адреса с координатами
qc_geo пусто или ≤ 1"] HOMES --> SCOPE["Скоуп по организации
клиента"] SCOPE --> BATCH["Батч-запросы:
NAS, блокировки, услуги"] BATCH --> JSON JSON --> RENDER["Кластеры на карте"]
Фильтр качества адреса. На карту попадают только адреса с qc_geo ≤ 1 — «точно дом» или «улица». Адреса, которые геокодер свёл к центру города (qc_geo ≥ 2), исключены: иначе сотни клиентов слиплись бы в одну точку и карта потеряла бы смысл.

Мультиорганизация

Точки, оборудование и заявки скоупятся по организации клиента. Важная деталь реализации: кеш партиционирован — ключ включает выбранную в шапке организацию и список доступных сотруднику компаний. Без этого первый открывший карту наполнил бы общий кеш своими данными, и остальные увидели бы чужих клиентов.

Если у организации нет ни одного адреса с точными координатами, карта не молчит, а объясняет причину и ведёт в редактор координат:

Пустая карта с объяснением причины

Сайдбар «Клиенты на NAS»

Клик по NAS-маркеру (кнопка «Клиенты») или по NAS-ссылке в попапе клиента открывает слайд-панель справа со списком клиентов выбранного NAS. Фильтр-чипы Все / Online / Offline со счётчиками, поиск (ФИО / логин / договор / IP), бесконечная прокрутка. Каждая строка — кликабельна (→ карточка клиента) и показывает: ФИО, бейдж «Заблок.» при блокировке, логин·IP, тариф, баланс (цветной ↑/↓) и иконки услуг.

Сайдбар клиентов NAS: тариф, баланс, услуги

Поток: маркер → попап → карточка / сайдбар

flowchart LR
    DATA["/admin/abonents/map/data/
points + NAS + заявки"] --> MAP["Карта Leaflet
кластеры"] MAP --> ABM(("Маркер
клиента")) MAP --> NASM(("Маркер
NAS")) MAP --> TKT(("Маркер
заявки")) ABM --> POP["Popup клиента
org · NAS · услуги"] POP -->|"ФИО"| DRAWER["Карточка клиента
→ блок «Услуги»"] POP -->|"Позвонить / E-mail"| DRAWERM["Карточка клиента
→ сообщения"] POP -->|"NAS-ссылка"| PANEL["Сайдбар «Клиенты NAS»
Все / Online / Offline"] NASM -->|"«Клиенты»"| PANEL PANEL -->|"строка"| DRAWER TKT --> TICKET["Карточка тикета
Поддержка СмИТ"] style DATA fill:#1e3a5f,stroke:#5BA89D,color:#fff style MAP fill:#134e4a,stroke:#43b77a,color:#fff style POP fill:#1e3a5f,stroke:#5BA89D,color:#fff style PANEL fill:#43b77a,stroke:#2d9a5f,color:#fff style DRAWER fill:#43b77a,stroke:#2d9a5f,color:#fff style DRAWERM fill:#14532d,stroke:#16a34a,color:#fff style TICKET fill:#78350f,stroke:#d97706,color:#fff

Тёмная тема и мобильный вид

Попап и сайдбар NAS полностью адаптированы под тёмную тему (контраст лейблов/значений ≥ WCAG AA). На мобильном панель фильтров — bottom-sheet.

Карта в тёмной теме — тёмные тайлы с русскими подписями
Карта на телефоне — поиск и фильтры свёрнуты в одну строку

Попап клиента

Клик по маркеру открывает карточку: договор, организация, NAS, тариф, адрес, баланс, точка подключения и значки подключённых услуг. Кнопки ведут в карточку клиента, звонок и почту — из карты можно начать работу, не открывая список.

Попап клиента на карте

Маркеры заявок открывают карточку тикета Поддержки в правой панели: история обращений, расшифровка звонка, приоритет и поле ответа.

Карточка заявки Поддержки с карты

Вес карты и скорость

Карта — самый тяжёлый JSON в системе: несколько тысяч точек одним ответом. Две правки изменили порядок величины:

ЧтоБылоСтало
Передача по сети4585 КБ380 КБ
Размер JSON4584 КБ2750 КБ
Загрузка страницы11,6 с2,6 с

Геокодирование адресов

Чтобы клиент появился на карте, его адрес (Homes.parent_id) должен иметь координаты lat и lon. Заполнение координат происходит автоматически через геокодеры — внешние сервисы, которые по строке адреса возвращают координаты.

Поддерживаются два провайдера:

ПровайдерГде настроитьЛимитыКоманда
DaData (рекомендуется для России) /admin/settings/integrations/?tab=geo«Адреса», поле DADATA_TOKEN. 10 000 запросов/день бесплатно (тариф «Бесплатный»), но не более 10 тыс. в месяц. manage.py geocode_homes_dadata [--re-geocode]
Яндекс.Геокодер (запасной) /admin/settings/integrations/?tab=geo«Адреса», поле YANDEX_GEOCODER_API_KEY. 25 000 запросов/день бесплатно (HTTP Геокодер). manage.py geocode_homes_yandex [--re-geocode] [--only-no-qc]

API-токены задаются в разделе «Настройки → Интеграции → География» (вкладка «География → Адреса»). Здесь же есть кнопка «Проверить» для теста подключения и счётчик геокодированных адресов.

Кто может править координаты. Сдвинутая точка дома уводит с карты всех его клиентов, поэтому правка адресного справочника доступна суперпользователю и группам из настройки ADDRESS_ADMIN_GROUPS (по умолчанию root, address). Остальным сотрудникам сохранение вернёт 403.
Запуск геокодирования

На рабочий сервер сейчас геокодировано ~95% адресов (4045 из 4261), из активных клиентов ~90% на карте. Чтобы геокодировать новый адрес автоматически — введите его в форме клиента, и при следующем прогоне команды (или nightly Celery-задаче) он попадёт на карту. Запустить геокодирование вручную:

docker compose exec web python manage.py geocode_homes_dadata
# или принудительно для всех (включая уже геокодированные):
docker compose exec web python manage.py geocode_homes_dadata --re-geocode

Команда:

Маркеры клиентов на адресах с qc_geo=2 (только город) на карте отображаются как «приблизительные» — несколько клиентов одного города могут лечь на одну точку. Для повышения точности проверьте, что у адреса заполнены поля «Улица» и «Дом».

Reverse-геокодинг (заполнение города/района по координатам)

Если у адреса есть координаты, но не заполнен город или район (после миграции из старых биллингов это бывает), используйте команду manage.py enrich_homes — она через Яндекс.Геокодер (обратное направление: координаты → адрес) восстанавливает текстовые поля.

Тайловый слой (фон карты)

Подложка карты выбирается на той же вкладке «География → Карта»: OpenStreetMap (бесплатно), 2ГИС (требует ключ), Yandex Maps. Дефолт — OSM. Стиль карточки в /admin/settings/integrations/?tab=geo показывает количество геокодированных адресов и точность (% домов с qc_geo=0).

Обещанный платёж (Promise Pay) — назначение и роль

Содержание раздела

Обещанный платёж (ОП) — короткий «кредит» клиенту: биллинг временно увеличивает его баланс, чтобы интернет работал ещё несколько дней до фактической оплаты. Реализован через Usluga id=30353 с system_type=11. Ключевые поля клиента: Abonents.promise_pay (сумма ₽) и promise_date_end (дата истечения).

Что такое ОП и зачем нужен

Сценарий типичного использования:

  1. Клиент в командировке, ушёл в минус по балансу. Биллинг блокирует (b_negbal);
  2. Клиент через ЛК / SMS / звонок в поддержку запрашивает ОП;
  3. Биллинг временно зачисляет ему ₽ на 5 дней (срок настраивается);
  4. Финансовая блокировка снимается, интернет возвращается через CoA Disconnect;
  5. Через 5 дней клиент пополняет счёт. Иначе ОП истекает, баланс возвращается к минусу, блокировка повторяется.
Экономика
ПараметрЗначениеГде настраивается
Лимитmax(месячная цена тарифа, |долг|+1₽) с cap 2× тарифаАвтоматически. Override: LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE
Длительность5 днейUsluga.end_count_days=5. Override: LK_PROMISE_PAY_MAX_DURATION_DAYS
Стоимость в день30 ₽/день — списывается ежедневно через billing_worker, если включеноUsluga 30353.price + флаг LK_PROMISE_PAY_DAILY_CHARGE (default 1)
Cooldown30 дней между двумя ОПLK_PROMISE_PAY_COOLDOWN_DAYS (default 30)
Min age30 дней с момента создания договораLK_PROMISE_PAY_MIN_AGE_DAYS (default 30)
Авто-погашение при оплатеЕсли YooKassa/W1/Generic webhook зачислил достаточно — ОП авто-отменяетсяLK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT (default 1)
Уведомление за N часовPush за 24ч до окончания + при истеченииMOBILE_PUSH_PROMISE_PAY_EXPIRE_HOURS (default 24)
Логика лимита: Если долг превышал цену — баланс не выходил в плюс и интернет не включался («активировал ОП, а интернета нет»). Теперь лимит масштабируется до покрытия долга, но ограничен 2× тарифа — защита от злоупотреблений через SMS. Если долг больше cap — клиент получает ОП, но баланс остаётся отрицательным; SMS-ответ предупреждает «Баланс X ₽ — недостаточно, требуется доплата».

Примеры (тариф 500 ₽):
Eligibility — 4 политики

Перед активацией _check_promise_pay_eligibility() в lk/views/promise_pay.py проверяет:

  1. LK_PROMISE_PAY_MIN_DEBT — минимальный долг (например, ОП доступен только при балансе < -100 ₽);
  2. LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE — макс. лимит ОП (если задан, перебивает расчёт по тарифу);
  3. LK_PROMISE_PAY_MIN_AGE_DAYS — минимальный «возраст» клиента (с момента create_date). Защита от мошеничества: создал договор и сразу взял ОП;
  4. LK_PROMISE_PAY_COOLDOWN_DAYS — пауза между ОП. Защита от циклов «брать ОП каждую неделю».

Все политики — настройки SystemSettings. Если значение = 0 — политика отключена. По умолчанию все включены.

Что происходит при активации

В одной транзакции:

  1. Создаётся UsersUsluga(usluga_id=30353, system_type=11, end_time=now+5дней, limit=<сумма ОП>);
  2. Создаётся FinanceOperations(op_type_id=31, op_summa=+limit, descr='[ЛК/SMS/Mobile] Обещанный платёж...');
  3. abonent.account.ostatok += limit (баланс пополняется);
  4. abonent.promise_pay = limit, abonent.promise_date_end = now+5дней — для бейджа в UI и баннера;
  5. Если был AbonentsBlock(b_negbal=true) — удаляется, Abonents.enabled=true;
  6. CoA Disconnect на NAS — текущая Reject-сессия разрывается, клиент переавторизуется, RADIUS отвечает Accept → интернет работает в течение секунд;
  7. Аудит — запись в AUDIT_OPERATIONS с подробной descr.
Каналы активации
КаналURL/действие
ЛК клиента/lk/promise_pay/ — кнопка с подтверждением
Mobile AppPOST /mobile-api/v1/finance/promise_pay + GET /finance/promise_pay
SMS-командаТекст «ОП» → биллинг шлёт условия → текст «ДА» в течение 10 мин
АдминкаКарточка клиента → вкладка «Услуги» → кнопка «+ Обещанный платёж» (modal с условиями)
REST API v2POST /rest_api/v2/promise_pay/ для внешних интеграций
Завершение ОП — 4 сценария

Есть четыре пути завершить активный ОП — все приводят к одному финалу: возврат лимита через обратную FinOp, очистка promise_pay/promise_date_end, удаление UsersUsluga, и при отрицательном балансе — авто-блок.

СценарийТриггерРеализация
A. Авто-погашение при оплате Webhook YooKassa/W1/Generic зачислил сумму, достаточную чтобы баланс после возврата лимита остался ≥0 lk/services/payment.py::_credit_abonent после успешного зачисления вызывает abonent.del_promise_pay(reverse_finance=True, reason='погашен оплатой ...'). Управляется LK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT
B. Истечение по таймеру Abonents.promise_date_end < now Celery beat check_expired_promises (раз в 15 мин). Вызывает del_promise_pay(reverse_finance=True, reason='истёк срок'). Если баланс < 0 — ставит b_negbal через abonent.block()
C. Ручное удаление через UI Оператор удаляет UU обещанного платежа (кнопка корзины во вкладке «Услуги») uslugi_delete_ajax детектирует system_type=11 или usluga_id ∈ {-3,-4} → выполняет тот же reverse-сценарий
D. Тогглер «Выкл.» во вкладке «Услуги» Оператор переключает UU.enabled с True на False uslugi_edit_ajax детектирует переход True→False для ОП-услуги → вызывает del_promise_pay(reverse_finance=True, reason='отключено вручную').
Дневная плата 30 ₽/день

Услуга 30353 имеет price=30 ₽, is_periodic=true, every_day=true.

Что происходит при активации:

  1. Если LK_PROMISE_PAY_DAILY_CHARGE=1UU.sched_date = now+1d 09:00;
  2. Каждый день в 09:00 billing_worker списывает Usluga.price (30 ₽) с лицевого счёта;
  3. Запись FinanceOperations(op_type=32, descr='Списание абонплаты ...');
  4. sched_date сдвигается на следующий день — пока ОП активен, плата идёт ежедневно;
  5. При завершении ОП (любой из 4 сценариев) UU удаляется → списания прекращаются.

Чтобы выключить дневную плату: LK_PROMISE_PAY_DAILY_CHARGE=0 в /admin/settings/system/ + переименовать услугу 30353 (убрать «30 руб/день» из названия).

Уведомления
Audit

Все операции с ОП пишутся в AUDIT_OPERATIONS с TABLE_NAME='USERS_USLUGA' через billing.services.audit_log.write_audit():

SMS-flow «ОП → ДА»: двухэтапная активация через SMS защищает от случайного нажатия. Сначала «ОП» проверяет 4 правила (eligibility), отправляет условия и сохраняет pending-state в Django cache на 10 минут. Только при получении подтверждения «ДА» (или «да», «yes», «ok», «+») биллинг активирует ОП. Pending-state хранится по последним 10 цифрам номера.
Сводка настроек ОП

Все настройки в SystemSettings (/admin/settings/system/):

КлючDefaultНазначение
LK_PROMISE_PAY_MIN_DEBT0Минимальный долг (₽) для активации. 0 = выкл
LK_PROMISE_PAY_MIN_AGE_DAYS30Минимальный возраст клиента (дни)
LK_PROMISE_PAY_COOLDOWN_DAYS30Интервал между двумя ОП (дни)
LK_PROMISE_PAY_MAX_DURATION_DAYS0Override длительности (0 = берём из Usluga.end_count_days)
LK_PROMISE_PAY_MAX_LIMIT_OVERRIDE0Override лимита (0 = автоматический расчёт max(тариф, долг+1))
LK_PROMISE_PAY_DAILY_CHARGE1Списывать Usluga.price ежедневно через billing_worker
LK_PROMISE_PAY_AUTO_CLOSE_ON_PAYMENT1Авто-погашение при достаточной оплате
LK_PROMISE_PAY_NOTIFY_BEFORE_HOURS24Окно уведомления перед истечением

Defaults применяются миграцией 0127_promise_pay_defaults. UPSERT — существующие значения не перезаписываются, на проде с 0 (выкл) останется 0 пока админ сам не включит.

Блокировки (AbonentsBlock) — назначение и роль

Содержание раздела

AbonentsBlock — таблица записей о блокировках клиента. Один клиент может иметь одну активную запись с несколькими булевыми флагами (один флаг = одна причина блокировки). Хранится в ABONENTS_BLOCK.

Три типа блокировок
ФлагКто ставитКогда
b_negbal 🔴 ФинансоваяСистема (Celery / accounts_payment_ajax)Баланс ушёл ниже LIMIT на счёте
b_admin 🟠 АдминистративнаяОператорРучное действие — нарушение договора, спам, заявление клиента
b_own 🟡 ДобровольнаяСам клиентЧерез ЛК / SMS-команду «ВЫКЛ» — пауза услуг (отпуск, командировка)
Блокировка vs Abonents.enabled

Это два разных механизма, иногда путают:

ПолеЧто этоКто меняет
Abonents.enabledГлавный «выключатель» клиента. Если false — RADIUS отвечает Reject, услуги не работаютМетод block() ставит в false, unblock() — в true (если других блокировок нет)
AbonentsBlockЗапись о причине блокировки (флаги b_negbal/b_admin/b_own)process_blocks Celery, accounts_payment_ajax, ручные действия

Корректная схема — блокировка всегда сопровождает enabled=false. Рассинхрон («есть запись блокировки, но enabled=true» или наоборот) — это баг, чинится через метод process_blocks или ручную правку через UI.

Жизненный цикл блокировки
  1. Создание:
    • process_blocks (Celery, раз в 5 мин) — сравнивает OSTATOK с LIMIT у всех клиентов и создаёт b_negbal;
    • accounts_payment_ajax — мгновенно блокирует если расход через UI ушёл в минус;
    • Кнопка «Заблокировать» в карточке клиента — ставит b_admin;
    • Abonents.add_promise_pay() при истечении срока ОП — возвращает b_negbal если баланс остался отрицательный.
  2. Активные действия при блокировке (см. Abonents.block()):
    • Создаётся / обновляется запись в ABONENTS_BLOCK;
    • Abonents.enabled = false;
    • Все Users.enabled = false (учётки) → RADIUS отвечает Reject при следующей авторизации;
    • На все NAS параллельно отправляется CoA Disconnect (см. ниже) — текущие сессии разрываются;
    • Пишется аудит-запись в AUDIT_OPERATIONS с table_name='ABONENTS_BLOCK';
    • Опционально — IPTV sync (отключить пакеты в TVIP / LFStream).
  3. Снятие:
    • process_blocks при пополнении баланса убирает b_negbal;
    • accounts_payment_ajax — мгновенно снимает b_negbal когда баланс >= 0;
    • Кнопка «Разблокировать» в карточке — снимает b_admin;
    • SMS-команда «ВКЛ» снимает b_own;
    • Если все флаги в записи стали falseAbonentsBlock удаляется, Abonents.enabled=true, услуги возвращаются.
CoA Disconnect — что происходит с активной сессией

Чтобы блокировка отрабатывала немедленно (а не через 5-15 мин когда сессия сама закроется), биллинг шлёт CoA Disconnect:

Без CoA Disconnect блокировка работала бы только при следующей перерегистрации клиента — а это иногда раз в сутки. С CoA — за 5-10 секунд.

Аудит блокировок: все блокировки и снятие блокировок теперь пишутся в AUDIT_OPERATIONS через Abonents.block / Abonents.unblock(). Все события блокировок видны во вкладке «Аудит» карточки клиента.

Лицевые счета (AdminAccounts) — назначение и роль

Содержание раздела

Лицевой счёт (AdminAccounts) — это финансовый «кошелёк» клиента в биллинге. Хранит баланс, бухгалтерские дебет/кредит, лимит блокировки, валюту. Хранится в таблице admin_accounts.

Зачем нужен лицевой счёт

Счёт решает четыре задачи:

  1. Учёт денег клиента — пополнения и списания идут через счёт. Текущий баланс — поле OSTATOK;
  2. Финансовая блокировка по порогу — если OSTATOK < LIMIT, Celery-задача process_blocks блокирует клиента (см. Блокировки);
  3. Бухучёт — поля DEBIT (поступления) и CREDIT (расходы) накапливают полные суммы для отчётности;
  4. Группа клиентов на одном счёте — можно привязать несколько Abonents к одному AdminAccounts (через Abonents.ACCOUNT_ID) для семейных тарифов или корпоративных схем.
Поля и связи
ПолеНазначение
OSTATOKТекущий баланс (×10^10, см. ниже)
DEBITСумма всех поступлений за всё время
CREDITСумма всех списаний за всё время
LIMITМинимально допустимый баланс (если UNLIMITED=false) — ниже идёт блокировка
UNLIMITEDБезлимитный счёт (не блокировать по балансу)
WARNПорог предупреждения: при OSTATOK < WARN отправляется уведомление
BONUS_SCOREБонусные баллы (программа лояльности)
CURRENCY_IDВалюта счёта (FK → currencies)
SLEEP_DATE«Заморозить» счёт до даты (счёт неактивен)
DB_MONEY_KOEF — почему ×10^10

Все денежные поля биллинга (OSTATOK, DEBIT, CREDIT, LIMIT, op_summa в FINANCE_OPERATIONS, price/summa в usluga) хранятся как целые числа, умноженные на 10^10. Это позволяет избежать ошибок округления плавающей запятой и хранить суммы с точностью до 10 знаков после запятой.

Кто меняет баланс
ИсточникЧто делает
billing_worker (Celery)Ежемесячное списание услуг по тарифу (op_type=32). Минусит OSTATOK
accounts_payment_ajaxРучное пополнение/списание оператором через UI «Касса»
YooKassa / W1 webhooksАвтоматическое пополнение после онлайн-оплаты
add_promise_payАктивация ОП — плюс к балансу + UU 30353
stornoСторнирование операции — обратное движение баланса с флагом storno=true
Каждое изменение баланса сопровождается записью в FINANCE_OPERATIONS и AUDIT_OPERATIONS. Не правьте OSTATOK SQL-запросом — это нарушит инварианты OSTATOK = DEBIT - CREDIT и историю операций. Всегда через UI или сервисный слой (_create_finop_atomic).

Финансовые операции (FinanceOperations) — назначение и роль

Содержание раздела

Финансовая операция (FinanceOperations) — каждое движение денег по лицевому счёту: пополнение, списание, обещанный платёж, начисление бонуса. Хранится в таблице finance_operations.

Зачем нужны финоперации
  1. Журнал движения денег — каждое изменение баланса должно сопровождаться записью FinOp. Это аудиторский след: всегда понятно почему баланс такой, как сейчас;
  2. Отчётность — отчёты «сколько собрано за месяц», «топ должников», «расход на услуги» строятся через агрегаты по FinanceOperations;
  3. Сторнирование — при ошибке оператора можно создать обратную операцию, не теряя истории. Поле storno=true + связь related_operation_id на оригинал;
  4. Идемпотентность платежей — для онлайн-оплат через webhook (YooKassa/W1) сверяется уникальность по `[txn:<ID>]` в descr — повторный webhook не создаст дубль.
Поля и связи
ПолеНазначение
OP_IDPK операции
OP_DATEДата операции (когда деньги фактически прошли). Может отличаться от SYSTEM_DATE (когда запись создана)
OP_SUMMAСумма ×10^10 (положительная = приход, отрицательная = расход). Знак определяется типом операции через op_sign
OP_TYPE_IDFK → fin_types — категория операции (см. ниже)
ABONENT_IDFK → abonents — кому принадлежит операция
ACCOUNT_IDFK → admin_accounts — на какой лицевой счёт
OWNER_IDFK → auth_user — какой оператор провёл (NULL для автоматических операций)
DESCRСвободный текст с деталями. Для платежей содержит [txn:<ID>]
NUMBERНомер документа (квитанция, чек, № платёжки)
STORNOtrue, если эта операция — обратная сторно к другой
RELATED_OPERATION_IDFK на саму себя — связь с оригиналом для пары storno↔original
USLUGA_IDFK → usluga — какая услуга была списана/начислена (если применимо)
AA_OSTATOK / AA_DEBIT / AA_CREDITСнимок состояния счёта на момент операции (для отчётов)
Типы операций

Справочник fin_types определяет:

Основные типы (из чтения кода):

op_type_idНазначениеЗнак
31Обещанный платёж — активация ОП (плюс к балансу)+
32Тарифное списание (billing_worker, ежемесячно)
другиеРучная проводка, единая касса, YooKassa, W1, корректировки и т.д. — справочник в /admin/settings/system/?tab=params±
Кто создаёт финоперации
ИсточникЧто делает
billing_worker (Celery, ежедневно)Списание тарифа (op_type=32) у всех активных клиентов
finops_list.py (UI «Финансовые операции»)Прямое CRUD оператором — модалка add/edit/delete
abonents.py:add_fin_op (карточка)Ручная проводка из карточки клиента кнопкой «+ Финоп»
accounts_list.py:_create_finop_atomicКасса — пополнение/корректировка через UI «Лицевые счета»
accounts_storno_last_ajaxСторно последней операции — кнопка в модалке счёта
lk/services/payment.py:_credit_abonentWebhook YooKassa/W1/Generic — пополнение после онлайн-оплаты
lk/views/promise_pay.pyАктивация обещанного платежа (op_type=31)
billing/services/sms_inbound.pySMS-команды (ОП через SMS)
Сторнирование

Сторно — это создание обратной операции, не удаление оригинала:

  1. Оператор открывает модалку счёта → клик «🔄 Сторно последней» (с подтверждением);
  2. Создаётся новая FinanceOperations с op_summa = -original.op_summa, тот же op_type, storno=true, related_operation_id = original.op_id;
  3. Оригинал помечается storno=true (для UI: зачёркнутый текст);
  4. Баланс счёта корректируется на сумму сторно;
  5. Аудит-запись фиксирует обе таблицы — FINANCE_OPERATIONS (с привязкой к клиенту) и ADMIN_ACCOUNTS (с привязкой к счёту).
Аудит всех операций. Все 5 точек создания/удаления (UI модалка, REST API, ChangeView, storno, billing_worker) обязательно создают запись в AUDIT_OPERATIONS с table_name='FINANCE_OPERATIONS' и object_id=op_id. Видно во вкладке «Аудит» карточки клиента с drill-down на саму операцию.

8.3. Счета

URL: /admin/Abonents/AdminAccounts/

Лицевой счёт (AdminAccounts) — кошелёк клиента. На нём лежат остаток, дебет, кредит, стоплата, лимиты и валюта. К одному счёту можно привязать нескольких клиентов (Abonents.account_id → AdminAccounts, FK PROTECT), а все финансовые операции ссылаются на счёт напрямую — поэтому история денег живёт своей жизнью и не рвётся при смене клиента.

Разделение простое: счёт отвечает на вопрос «сколько денег сейчас», финоперации — «откуда они взялись». На проде 6309 счетов.

Что такое счёт: карта понятий

- Лицевой счёт
  - Деньги
    - Остаток
    - Дебет
    - Кредит
    - Стоплата
    - Баланс = остаток + дебет − кредит
  - Правила
    - Порог предупреждения
    - Порог отключения
    - Безлимит и дата
    - Валюта
  - Связи
    - Клиенты много к одному
    - Финансовые операции
    - Банковские выписки
    - Организация через клиента
  - Действия
    - Пополнить
    - Корректировка
    - Сторно последней
    - Привязать отвязать клиента

Отсюда и главная путаница раздела: «остаток» и «баланс» — разные величины. Остаток — одно поле, баланс считается по формуле. В карточке счёта формула выведена прямо под крупной цифрой, чтобы не приходилось помнить.

Как деньги попадают на счёт

flowchart LR
    W["Webhook оплаты
ЮKassa · W1"] --> FO B["Банковская
выписка"] --> FO O["Оператор:
Пополнить"] --> FO C["Оператор:
Корректировка"] --> FO M["billing_worker
абонплата"] --> FO FO(["FinanceOperations"]) --> ACC["AdminAccounts.ostatok"] ACC --> BAL{"Баланс
отрицательный?"} BAL -->|"да"| BLK["Блокировка b_negbal
+ CoA Disconnect"] BAL -->|"нет"| OK["Доступ активен"] ACC --> LIM{"Ниже порога
предупреждения?"} LIM -->|"да"| MSG["SMS · Push
уведомление"]

Ни одна кнопка в разделе не меняет остаток «напрямую»: любое движение денег создаёт финоперацию, а остаток — её следствие. Поэтому удалить операцию нельзя без последствий, а для отмены есть сторно.

Список счетов

Список лицевых счетов: KPI, чипы, колонки Счёт / Клиент / Тариф (₽/м.) / Баланс / Должник с / Последняя операция

Цвет не единственный сигнал: рядом с балансом стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати.

Как счёт может быть «без клиента»

Лицевой счёт (AdminAccounts) и клиент (Abonents) — разные сущности. Обычная связь идёт от клиента к счёту (Abonents.account_id). Но есть и второй путь: отдельный видео-счёт модуля видеонаблюдения привязан к клиенту через VideoObject.account, а не через основной счёт.

Поэтому «без клиента» = на счёт не ссылается ни основной клиент, ни видео-объект. Видео-счета показывают клиента (с маркером видео) и не считаются сиротами — иначе реальные видео-счета клиентов попадали бы в «Удалить сироты». Настоящие сироты — это, как правило, тестовые/демо-записи или счёт, у которого клиента удалили жёстко.

flowchart LR
    AB["Клиент
Abonents"] -->|"account_id"| ACC["Лицевой счёт
AdminAccounts"] VO["Видео-объект
VideoObject"] -->|"abonent"| AB VO -->|"account (видео-счёт)"| ACC2["Отдельный
видео-счёт"] ACC2 -.->|"клиент через видео-объект"| AB ORPHAN["«Без клиента»"] -.->|"нет ни того, ни другого"| ACC3["Счёт-сирота"]
Скорость списка

Название тарифа считается по услугам тарифа, и str(tarif) ради этого ходит в БД дважды на каждый тариф. В списке это превращалось в 299 запросов на 100 строк и 2,7 с ответа, а выгрузка всех 6309 счетов в CSV занимала 47 секунд.

Теперь подписи считаются пачкой (Tarif.bulk_labels(ids)): те же две агрегации выполняются один раз на всю страницу.

ОперацияБылоСтало
Список, 100 строк299 запросов · 2 698 мс8 запросов · ~300 мс
CSV всех счетов47,3 с1,3 с

Мультиорганизация и изоляция доступа

У AdminAccounts нет своего поля организации — она берётся у клиента счёта. Поэтому «счёт без организации» означает «клиент без организации», и гарантия стоит на клиенте.

Кто какие счета видит

Сотрудник видит только счета своих организаций. Проверка стоит не только в списке, но и на каждом действии с конкретным счётом — иначе чужой счёт открывался бы подстановкой номера в адрес.

flowchart LR
    REQ["Запрос сотрудника"] --> ALLOW{"allowed_orgs"}
    ALLOW -->|"None — все"| FULL["без фильтра"]
    ALLOW -->|"свои орг."| SCOPE["filter_qs_by_org
через клиента"] FULL --> ACT{"действие"} SCOPE --> ACT ACT -->|"список"| L["accounts_json"] ACT -->|"карточка"| G["accounts_get"] ACT -->|"деньги"| P["payment · storno"] ACT -->|"массово"| BK["bulk · csv"] G --> GUARD["чужой счёт → 404"] P --> GUARD BK --> F403["чужой в списке → 403"]

Ответ на чужой счёт — 404, а не 403: одинаковая реакция на «нет такого» и «есть, но не ваш» не даёт перебором выяснить, какие счета существуют. Массовые действия отвечают 403, если хоть один выбранный счёт вне доступа, — молча пропустить часть выборки хуже, чем отказать целиком.

Карточка счёта

Открывается кликом по строке или по названию. Восемь вкладок слева, действия с деньгами — на первой. Параметр ?edit=<id> открывает карточку сразу при загрузке страницы (для ссылок из других разделов).

Карточка счёта — вкладка Баланс, вкладки «Фин. операции» и «Платеж. системы»

flowchart TB
    subgraph T["Вкладки карточки"]
      direction LR
      T1["① Баланс
деньги и действия"] T2["② Услуги и тарифы
ожидаемое списание"] T3["③ Платёжные системы
история по каналам"] T4["④ Параметры
лимиты и пороги"] T5["⑤ Клиенты
привязка"] T6["⑥ Финоперации
последние 50"] T7["⑦ Банк. выписки
связанные строки"] T8["⑧ Уведомления
SMS · Email · Push"] end
① Баланс

Крупная цифра баланса, под ней формула баланс = остаток + дебет − кредит, ниже — четыре составляющие и производные величины (бухгалтерский, на конец месяца). Три кнопки: Пополнить, Корректировка, Сторно последней.

② Услуги и тарифы

Карточка счёта — услуги и тарифы

Услуги всех клиентов счёта с ценами и ожидаемым списанием за месяц. Каскад цены: uu.summ → usluga.summa → usluga.price. Бонусы (отрицательная сумма или «бонус» в названии) вынесены отдельно.

③ Платёжные системы

Карточка счёта — история платежей по каналам

Приходы за 90 дней, сгруппированные по каналу (ЮKassa, Wallet One, банковская выписка, ручная проводка, EBS). Канал определяется по operator_name, а при пустом — по меткам в описании. Ниже — статус интеграций со ссылками на настройки: видно, настроен ли канал вообще.

④ Параметры

Карточка счёта — параметры и лимиты

⑤ Клиенты

Карточка счёта — привязанные клиенты

Привязанные клиенты с бейджем «основной» (первый по ID) и статусом. Кнопка Привязать открывает поиск; если клиент уже на другом счёте, появляется чекбокс Перенести — молча увести клиента с чужого счёта нельзя.

⑥ Финансовые операции

Карточка счёта — финансовые операции

Последние 50 операций с иконкой источника и цветом по знаку; сторнированные зачёркнуты. Ссылка Все операции по счёту ведёт в Финансовые операции с фильтром по счёту.

⑦ Банковские выписки

Карточка счёта — банковские выписки

Строки выписок, связанные с операциями счёта (BankStatementOperation.fin_operation_id): банк, плательщик, ИНН, назначение платежа. Нужна, когда клиент говорит «я платил через банк», а платёж не виден.

Пополнение, корректировка, сторно
Пополнение счёта
Пополнение — только приходные типы
Корректировка счёта
Корректировка — все типы, знак от типа

Под формой — предпросмотр: текущий баланс, сумма, новый баланс. Сервер выполняет всё в одной транзакции с SELECT FOR UPDATE на счёт, поэтому два оператора не «потеряют» одно из зачислений.

Мобильная версия

На телефоне у раздела другая задача: посмотреть баланс и принять оплату, а не разбирать журнал. Поэтому таблица не ужимается, а перестраивается.

Список счетов на телефоне
Список: поиск, фильтры, добавление — одной строкой
Карточка счёта на телефоне
Карточка — полноэкранная панель

Тёмная тема

Список счетов в тёмной теме

Цвета раздела заданы токенами, поэтому тема переключается без отдельного набора правил.

Защита от удаления

Счёт удаляется только когда выполнены все условия: к нему не привязан ни один клиент, по нему нет ни одной финансовой операции и на него не ссылается видео-объект. Иначе сервер отвечает 400 с понятной причиной («Сначала отвяжите N клиентов…», «У счёта M операций — удаление запрещено для сохранения истории», «видео-объектов: K»).

Отдельная защита у видео-счетов: они принадлежат клиенту через VideoObject и не считаются сиротами, поэтому массовое «Удалить сироты» их не трогает даже при пустом балансе. См. Как счёт может быть «без клиента».

Смысл в том, что счёт — это история денег. Удалив его, вы теряете основание для всех прошлых начислений; в спорной ситуации восстановить картину будет нечем.

Аудит

Каждое действие пишется в AuditOperations (table_name='ADMIN_ACCOUNTS') с оператором и описанием изменения: создание и правка счёта (с diff вида «warn 100→200»), пополнение и корректировка (с op_id и типом), сторно, привязка и отвязка клиента, удаление (с балансом на момент удаления).

Endpoints

URLМетодНазначение
/admin/Abonents/AdminAccounts/GETСтраница списка
/admin/Abonents/accounts_json/GETСписок: q, chip, page, per_page, sort. Возвращает items, KPI и show_org
/admin/Abonents/accounts_get/<id>/GETСчёт + клиенты + 50 операций + 20 уведомлений + организация
/admin/Abonents/accounts_crud/[<id>/]POSTСоздание / правка / удаление (_delete=1)
/admin/Abonents/accounts_payment/<id>/POSTПополнение и корректировка
/admin/Abonents/accounts_storno/<id>/POSTСторно последней операции
/admin/Abonents/accounts_link/<id>/POSTПривязать клиента (force=1 — перенос с другого счёта)
/admin/Abonents/accounts_unlink/<id>/<abonent_id>/POSTОтвязать клиента
/admin/Abonents/accounts_services/<id>/GETУслуги и тарифы клиентов счёта
/admin/Abonents/accounts_payments_summary/<id>/GETПриходы по каналам + статус интеграций
/admin/Abonents/accounts_bank_ops/<id>/GETСвязанные строки банковских выписок
/admin/Abonents/accounts_bulk/POSTМассово: валюта / лимиты / удаление сирот
/admin/Abonents/accounts_csv/GETCSV (UTF-8 BOM, разделитель ;)

Все endpoint'ы, принимающие <id>, проверяют доступ к счёту по организации сотрудника.

Денежные правила

Суммы в БД хранятся как целое × 1010 (DB_MONEY_KOEF). Конвертация автоматическая в MoneyField: при отображении делится, при сохранении умножается. В UI и API — всегда рубли с двумя знаками.

Обслуживание

Обе идемпотентны — трогают только записи с пустой организацией. После перехода гарантии на уровень модели они нужны разве что после массового импорта.

8.4. Финансовые операции

URL: /admin/Abonents/FinanceOperations/

Финансовая операция (FinanceOperations, таблица finance_operations) — атомарная запись в журнале движения денег. Привязана к лицевому счёту (account_id → AdminAccounts) и к клиенту (abonent_id → Abonents). Тип операции (op_type → FinTypes) определяет знак (op_sign: +1 приход, −1 расход). Сумма хранится в БД как op_summa × 10^10.

Это самая большая таблица биллинга — на проде более 450 000 записей. Почти всё, что клиент видит в ЛК на вкладке «История», и всё, что бухгалтер сдаёт в отчётности, — строки отсюда.

Откуда берутся операции и на что влияют

Финоперации не создают «просто так»: каждая — след реального события. Обратная сторона в том, что операция почти никогда не существует в одиночку — она двигает баланс, а баланс двигает доступ клиента в сеть.

flowchart LR
    W["Webhook оплаты
ЮKassa · W1"] --> FO B["Банковская
выписка"] --> FO M["billing_worker
абонплата"] --> FO P["Обещанный
платёж"] --> FO O["Оператор
вручную"] --> FO E["EBS-импорт
история"] --> FO FO[("finance_operations")] --> BAL["ostatok
+= op_summa"] BAL --> BLK{"Баланс
отрицательный?"} BLK -->|"да"| B1["b_negbal
+ CoA"] BLK -->|"нет"| B2["Доступ активен"] FO --> KKM["Чек в ОФД
54-ФЗ"] FO --> LK["История в ЛК
и мобильном"]

Практический вывод: операцию нельзя «просто удалить». Удаление не откатывает остаток счёта автоматически и рвёт связь с банковской выпиской и чеком. Для отмены влияния используйте сторно (обратная операция + флаг storno=True) — см. Сторнирование.

Список операций

Финансовые операции — список с фильтрами и колонкой организации

Раздел построен как кастомная страница с AJAX-загрузкой и модальным редактированием — иначе журнал такого размера не открыть за приемлемое время.

Цветовое кодирование. Цвет не единственный сигнал — рядом с суммой стоит стрелка направления, поэтому строка читается и при дальтонизме, и в чёрно-белой печати:

Пагинация: почему курсор, а не OFFSET

Классический LIMIT/OFFSET заставляет PostgreSQL пройти и выбросить все предыдущие строки. На глубине 100 000 записей это 94 мс против 0,3 мс у курсора (замер EXPLAIN ANALYZE на проде). Поэтому подгрузка идёт по составному курсору (op_date, op_id) — составному потому, что даты повторяются и одной даты для однозначного «продолжи отсюда» не хватает.

sequenceDiagram
    participant JS as Список в браузере
    participant V as finops_list_json
    participant DB as PostgreSQL
    JS->>V: первая порция (page=1)
    V->>DB: ORDER BY op_date DESC, op_id DESC LIMIT 50
    DB-->>V: 50 строк + COUNT(*)
    V-->>JS: items + total + next_cursor
    Note over JS: оператор прокрутил вниз
    JS->>V: after_date + after_id (курсор)
    V->>DB: (op_date, op_id) < курсор LIMIT 50
    DB-->>V: 50 строк за 0,3 мс
    V-->>JS: items + next_cursor, total = -1

total = -1 на подгрузках — это не ошибка: общее число между порциями не меняется, поэтому COUNT(*) (около 0,12 с на 450 тыс. строк) считается только для первой порции, а клиент помнит прежнее значение.

Мультиорганизация

FinanceOperations.organization — денормализованный внешний ключ: он дублирует организацию клиента, чтобы фильтр по организации не делал JOIN на каждую из сотен тысяч строк.

Тот же бейдж выводится в шапке карточки операции, чтобы при разборе обращения было видно, к какой компании относится платёж.

Изоляция доступа

Скоупинг применяется не только к списку, но и к чтению и правке конкретной операции. Иначе сотрудник одной компании мог бы читать и менять чужие платежи простым перебором op_id в адресной строке.

flowchart LR
    REQ["Запрос"] --> ALLOW{"allowed_orgs"}
    ALLOW -->|"None"| FULL["без фильтра"]
    ALLOW -->|"свои орг."| SCOPE["filter_qs_by_org"]
    FULL --> ACT{"действие"}
    SCOPE --> ACT
    ACT -->|"список"| LIST["list_json"]
    ACT -->|"карточка"| DET["detail_json"]
    ACT -->|"правка"| CRUD["crud_ajax"]
    DET --> GUARD["чужая → 404"]
    CRUD --> GUARD
    CRUD --> ABCHK["чужой клиент → 403"]

Ответ на чужую операцию — 404, а не 403: одинаковое сообщение для «нет такой» и «есть, но не ваша» не позволяет перебором выяснить, какие номера операций существуют.

Открывается кликом по 👁 или по ячейке «Тип операции». Это карточка на две колонки: слева — сама операция и её связи, справа — клиент, снапшот счёта и аудит. Пустые секции скрываются: у миграционных записей из EBS обычно не заполнены кассовые и 1С-поля, и секция просто не рендерится.

Карточка финансовой операции — режим просмотра

Модалка v915 (variant B)

Двухрежимная карточка пришла на смену мини-форме: до неё была мини-форма из шести полей, по которой нельзя было разобрать обращение, не открывая полную форму Django.

Иконки источника

Рядом с описанием — цветная иконка, показывающая, откуда пришла операция. Источник вычисляет эвристика _resolve_source(op) в billing/views/finops_list.py по комбинации operator_name, меток в descr, usluga_id и op_type_id:

Чипы связей рядом с именем клиента (📎🔗💳💰) — флаги наличия usluga, fin_op, credit_type, operator_pay_id_str. В панели фильтров есть отдельный фильтр «Источник» со всеми восемью видами.

Режим просмотра

Клик по имени клиента в карточке открывает выезжающую панель клиента — можно посмотреть, кому принадлежит платёж, не теряя открытую операцию.

Режим редактирования

Карточка операции — режим редактирования со связями

Та же сетка: слева форма основных полей, справа — связи операции через Select2 с подгрузкой по AJAX.

Создание операции

Создание финансовой операции

Кнопка «Добавить» открывает ту же карточку с пустой формой и текущей датой. Организация проставится автоматически из выбранного клиента.

Мобильная версия

На телефоне у раздела другая задача: не «работать с журналом», а быстро посмотреть последние платежи — например, стоя у клиента. Поэтому таблица не ужимается, а перестраивается.

Мобильный список операций
Список: дата, клиент, сумма
Лист фильтров на телефоне
Фильтры — в нижнем листе
Карточка операции на телефоне
Карточка — полноэкранная панель

Тёмная тема

Финансовые операции в тёмной теме

Цвета раздела заданы токенами (tokens.css), поэтому тема переключается без отдельного набора правил. Суммы в тёмной теме используют осветлённые оттенки — тёмно-зелёный и тёмно-красный на тёмном фоне не проходят по контрасту.

Достоверность чисел и защита важных действий

Журнал рассчитан на большие объёмы: около 450 тысяч операций листаются без задержки, суммы хранятся без потери копеек. Доступ закрыт организацией — и к списку, и к каждой операции по прямой ссылке. Отдельно проработаны две вещи: достоверность и понятность чисел и защита действий, которые нельзя отменить.

Список финансовых операций
Список: колонка «Орг.» — эффективная организация, ⓘ у «Единой кассы»
Модалка операции с расшифровкой
Модалка: строка «Расшифровка» — оплачено / зачислено / комиссия
Дропдаун источника
Фильтр «Источник» — 8 типов с цветными значками
Восемь источников операции

Источник определяется на лету по комбинации сигналов (operator_name + метки в descr + op_type_id + связи), без отдельного поля в БД:

YooKassa
эквайринг, метка [txn:…]
Wallet One
эквайринг W1
Банковская выписка
метка [bank_op:N]
EBS (миграция)
operator_name=EBS
Обещанный платёж
usluga 30353 / −3 / −4
Ручная проводка
op_type 7 / 9
Ежемесячное списание
op_type 3 / 31 / 32
Смена тарифа
descr = «Смена тарифа…»
flowchart LR
    OP["Операция
FinanceOperations"] --> R{"_resolve_source"} R -->|"метка txn / оператор"| Y["YooKassa"] R -->|"оператор wallet"| W["Wallet One"] R -->|"метка bank_op"| B["Банковская выписка"] R -->|"оператор EBS"| E["EBS (миграция)"] R -->|"услуга ОП"| P["Обещанный платёж"] R -->|"тип 7 или 9"| M["Ручная проводка"] R -->|"тип 3 / 31 / 32"| MO["Ежемесячное"] R -->|"описание «Смена тарифа»"| TC["Смена тарифа"]
Сквозной drill-down до чека ОФД

Операцию можно проследить от письма банка до фискального чека и обратно — карточка v915 связывает все звенья:

flowchart LR
    IMP["Письмо-импорт
выписки"] <--> QUE["Очередь
модерации"] QUE --> FO["Финансовая
операция"] FO --> OFD["Чек ОФД
54-ФЗ"] FO -->|"обратная ссылка"| BOP["Банковская
операция"] BOP --> IMP
Пробитие чека: подтверждение и аудит

«Пробить чек» — необратимое действие: чек уходит в ОФД и оттуда в ФНС. Поэтому он требует подтверждения, а результат пишется в журнал аудита. Удаление операции подтверждается так же.

flowchart LR
    BTN["«Пробить чек»"] --> SC{"SmitConfirm
предупреждение"} SC -->|"Отмена"| X["ничего"] SC -->|"Пробить"| API["finops_fiscalize"] API --> FM["FiscalManager
в ОФД"] API --> AUD["AuditOperations"]
Достоверность и понятность чисел

Типы операций (FinTypes)

Справочник типов хранится в fin_types. Поле op_sign определяет знак суммы:

UI пополнения счёта (см. Счета → Пополнение) фильтрует список типов по op_sign > 0; UI корректировки показывает все типы, направление выбирает оператор.

Редактирование справочника

URL: /admin/dictionary/FinTypes/ (Справочники → Типы фин. операций).

Список типов финансовых операций

Список с поиском, чипами-фильтрами по знаку и счётчиком использований в журнале. Создание и редактирование — в модальном окне с радиогруппой Приход (+1) / Нейтр. (0) / Расход (−1).

Модалка редактирования типа с предупреждением об использовании

Поля: type_id (автоинкремент, read-only), op_name (название, до 256 символов), op_sign (+1 / 0 / −1), op_descr (описание), is_common (чекбокс «Основной» — ходовые типы показываются первыми, чтобы оператор не искал их среди 47 legacy-типов).

Защита удаления: тип нельзя удалить, если он использован хотя бы в одной операции. Причина не только в истории: часть type_id жёстко зашита в коде (23 — YooKassa в webhook, 32 — ежемесячное списание тарифа в billing_worker), и удаление сломает интеграции.

Аудит: создание, изменение и удаление пишутся в AuditOperations (table_name='FIN_TYPES').

Когда добавлять новый тип: при подключении новой платёжной системы (СБП, T-Bank) или появлении специфической бухгалтерской операции. Перед использованием нового type_id в коде убедитесь, что значение берётся из FinTypes.objects.get(...), а не зашито числом.

Связи и снапшот

Операция — узел, а не изолированная строка. Ниже — что к ней присоединено:

flowchart TB
    FO["FinanceOperations"]
    AB["Abonents
клиент"] --- FO AA["AdminAccounts
лицевой счёт"] --- FO FT["FinTypes
тип · op_sign ±1"] --- FO ORG["Organization
денорм."] --- FO FO --- U["auth_user
кто провёл"] FO --- US["Usluga
за что списано"] FO --- CT["CreditType
оплата по"] FO --- SELF["счёт-родитель
сторно"] BSO["строка
выписки"] --> FO FO --> KKM["AtolConfig
чек ОФД"]

Онлайн-касса (ФР)

Поля для интеграции с ОФД и кассовой техникой:

Настройки фискализации — в /admin/settings/atol_config/.

Внешние операторы

Для операций, пришедших через webhook платёжных систем (/admin/settings/payment/):

Идемпотентность по operator_pay_id_str: повторный webhook на ту же транзакцию не создаёт дубль. Это не теоретическая предосторожность — платёжные системы штатно повторяют доставку, пока не получат подтверждение.

Синхронизация с 1С

Endpoints

URLМетодНазначение
/admin/Abonents/FinanceOperations/GETHTML-страница списка
/admin/Abonents/finops_json/GETСписок: q, op_type, source, date_from, date_to, page, per_page, курсор after_date + after_id. Возвращает items, total, next_cursor, has_more, show_org
/admin/Abonents/finops_detail/<op_id>/GETПолный контекст карточки (операция, клиент, связи, выписка)
/admin/Abonents/finops_crud/[<op_id>/]POSTСоздание / изменение / удаление (_delete=1)
/admin/Abonents/finops_credit_types/?q=GETПодсказки: типы оплаты
/admin/Abonents/finops_staff_users/?q=GETПодсказки: сотрудники
/admin/Abonents/finops_uslugi/?q=GETПодсказки: услуги
/admin/Abonents/finops_search/?q=GETПоиск операции-родителя
/rest_api/v2/FinanceOperations/GET / POSTREST API v2 (CRUD)

Обслуживание данных

manage.py backfill_finops_org [--apply] — проставляет организацию операциям, у которых она пустая: сначала из организации клиента, затем организацию по умолчанию для остатка. Без --apply только показывает, что будет сделано.

Команда идемпотентна — трогает только записи с пустой организацией. Это осознанно: у части операций организация уже проставлена верно и отличается от организации по умолчанию, и «проставить всем одну» затёрло бы их и сломало изоляцию доступа.

Чем отличается от Журнала платежей

В системе два раздела поверх одной таблицы finance_operations:

Разница только в UI и наборе фильтров — данные одни и те же.