Skip to content

Реестр обязательств и постоянные платежи ​

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

Реализовано: модуль internal/modules/obligations, миграция 0134 (таблица obligations, коды obligations.read / obligations.manage). Спецификация «Структура RMS», п.6 и п.11.


Схема ​

Финотдел: "Заводим позицию: контрагент, сумма, plan_date, ответственный"
    ↓
Система: "Статус считается от plan_date: on_track → at_risk (за warning_days) → overdue"
    ↓
Система: "За 3 дня до plan_date needs_escalation=true — без действия сотрудника"
    ↓
Финотдел: "Ставим fact_date"
    ↓
Система: "Статус fulfilled; если это постоянный платёж — создаём позицию на следующий месяц"

Сущность ​

Таблица obligations, DTO Obligation:

ПолеОписание
project_id, project_nameПроект (опц.; у постоянных платежей обычно пусто). ON DELETE SET NULL
counterparty_id, counterparty_nameКонтрагент из companies (опц.)
titleНазвание, обязательно, до 300 символов
directionincoming (нам должны) / outgoing (мы должны)
amountСумма, NUMERIC(14,2), не отрицательная
plan_dateПлановая дата, обязательна, хранится без времени
fact_dateФактическая дата; заполнена — обязательство исполнено
responsible_user_id, responsible_nameОтветственный сотрудник (опц.)
notificationПризнак уведомления: sent, not_sent, not_required (по умолчанию)
commentКомментарий до 2000 символов
warning_daysГоризонт предупреждения по каждой статье отдельно, 0–365; не передан — 7; 0 — «риск» только в день срока
recurrence_dayДень месяца 1–31 для постоянного платежа; null — разовое обязательство
parent_idПозиция, из которой порождена текущая (цепочка повторов)

Вычисляемые поля (не хранятся, считаются на каждом чтении от текущей даты):

ПолеЗначение
statuson_track, at_risk, overdue, fulfilled
is_recurringrecurrence_day != null
needs_escalationне исполнено и до plan_date осталось <= 3 дней (включая просрочку)
days_to_planдней до плановой даты; отрицательное — просрочка

Статусы ​

Статус вычисляется в status.go (ComputeStatus) по календарным дням (время отбрасывается):

СтатусУсловие
fulfilledfact_date заполнена — вне зависимости от сроков
overdueplan_date < сегодня
at_riskplan_date <= сегодня + warning_days
on_trackиначе
                warning_days            plan_date
   on_track ────────┬──── at_risk ────────┬──── overdue
                    │        ▲            │
                    │   needs_escalation  │
                    │   (за 3 дня и позже)│
   fact_date заполнена → fulfilled (needs_escalation=false)
  • warning_days задаётся по каждой статье; если не указан — DefaultWarningDays = 7.
  • needs_escalation (EscalationLeadDays = 3) — признак вычисляемый, эскалация срабатывает сама, без действия сотрудника. Уведомления по нему пока не отправляются (см. дорожную карту, этап 3).
  • Фильтр status= в списке применяется к вычисляемому статусу.

Постоянные платежи ​

Постоянный платёж — та же позиция с заполненным recurrence_day. Правила (service.Update):

  1. Позиция считается «только что исполненной», если до обновления fact_date был пуст, а после — заполнен.
  2. Если у неё есть recurrence_day, репозиторий в одной транзакции закрывает её и создаёт копию на следующий период (FulfilRecurring) с parent_id = текущая позиция и fact_date = null. Если следующий период уже был создан (позицию переоткрыли и исполнили снова), второй не создаётся. Два одновременных исполнения не проходят оба: второе получит 409 obligations_already_fulfilled.
  3. Следующая дата — NextOccurrence(plan_date, recurrence_day): следующий месяц относительно плановой даты (не относительно fact_date), число = recurrence_day. Если в месяце нет такого числа (31-е в феврале), берётся последний день месяца — платёж не должен пропадать.
  4. Закрытая позиция остаётся в реестре как история периода; цепочку можно восстановить по parent_id.

Пример: аренда с recurrence_day=31, plan_date=2026-01-31. После проставления fact_date появляется позиция с plan_date=2026-02-28, затем 2026-03-31.

Суммы (amount, overdue_amount, at_risk_amount) — точный денежный тип money.Money, в JSON — число с копейками.

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

СобытиеКомуТип
Создан следующий период постоянного платежаОтветственныйobligation.recurring_created
Позиция в зоне эскалации (≤ 3 дней до плановой даты или просрочена, не исполнена)Ответственный и все пользователи с ролью FIN_DIRobligation.escalation

Эскалации рассылает воркер (obligations.EscalationWorker, процесс notifications_worker) раз в час; ключ дедупликации — позиция, плановая дата и получатель, поэтому по одной позиции уходит одна эскалация на срок.


Права ​

Объекты матрицы прав (Матрица прав RMS): разовые обязательства — obligations, постоянные платежи — recurring_payments. Проверка идёт в handler (requireRead / requireWrite), маршруты закрыты только auth middleware.

Рольobligationsrecurring_payments
OWNER, FIN_DIR, FIN_ASSISTFF
OPSRR
LEGALR-
остальные--

Какой объект проверяется:

  • GET /obligations — по query recurring=true (постоянные) или без него (obligations);
  • GET /obligations/:id — по is_recurring найденной позиции;
  • POST — по recurrence_day в теле; PUT — по recurrence_day в теле; DELETE — по is_recurring позиции.

Отказ — 403 с кодом obligations_forbidden («Реестр обязательств доступен только финансовому отделу»). Для legacy-RBAC миграция 0134 заводит коды obligations.read / obligations.manage, но middleware их не проверяет — решает матрица.


API ​

МетодПутьДоступОписание
GET/api/v1/obligationsreadРеестр со сводкой. Query: project_id, responsible_user_id, direction, status, recurring=true, open=true, limit, offset
POST/api/v1/obligationswriteСоздать позицию
GET/api/v1/obligations/:idreadКарточка
PUT/api/v1/obligations/:idwriteОбновить; проставление fact_date у постоянного платежа создаёт следующий период
DELETE/api/v1/obligations/:idwriteУдалить

Тело POST/PUT (obligationRequest): title (обяз.), direction (обяз.), plan_date (обяз., YYYY-MM-DD или RFC3339), amount, fact_date, project_id, counterparty_id, responsible_user_id, notification, comment, warning_days, recurrence_day. Несуществующий проект/контрагент/пользователь — 400 с полем (obligations_project_invalid и т.п.). PUT заменяет позицию целиком: проект, контрагент и ответственный, не переданные в запросе, очищаются — форма всегда отправляет их. DELETE позиции, на которую ссылаются платежи, — 409 obligations_has_payments (раньше внешний ключ молча отвязывал платежи от основания).

Ответ списка — собственный конверт (не transport.RespondList):

json
{
  "items": [ { "id": 1, "title": "Аренда", "status": "at_risk", "needs_escalation": false, "...": "..." } ],
  "total": 12, "limit": 50, "offset": 0,
  "summary": {
    "total": 12, "on_track": 6, "at_risk": 3, "overdue": 2, "fulfilled": 1,
    "escalated": 4, "overdue_amount": 150000, "at_risk_amount": 80000
  }
}

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


Что важно для фронта ​

  • Не считайте статус на клиенте — берите status, needs_escalation, days_to_plan из ответа; они зависят от серверной даты и warning_days конкретной позиции.
  • Постоянный платёж «закрывается» обычным PUT с fact_date; после ответа перезапросите список — появится позиция следующего месяца с parent_id.
  • Фильтр open=true оставляет только неисполненные (fact_date IS NULL) — используйте его для рабочего экрана, а полный список — для истории.
  • Разделяйте вкладки «Обязательства» и «Постоянные платежи» по recurring=true: у LEGAL есть чтение первых, но нет вторых.
  • Кнопки создания/редактирования показывайте по canWrite("obligations") / canWrite("recurring_payments") из /org/me.
  • amount приходит числом (JSON number); суммы в сводке — тоже числа.

Загружаем документацию…