Реестр обязательств и постоянные платежи
Реестр обязательств — закрытый контур финансового отдела: что и кому мы должны заплатить, что должны заплатить нам, с плановой и фактической датой, ответственным и признаком уведомления. Постоянные платежи (зарплаты, налоги, аренда, обслуживание кредита) живут в той же таблице и заводятся один раз: при исполнении текущего периода система сама создаёт следующий.
Реализовано: модуль
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 символов |
direction | incoming (нам должны) / 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 | Позиция, из которой порождена текущая (цепочка повторов) |
Вычисляемые поля (не хранятся, считаются на каждом чтении от текущей даты):
| Поле | Значение |
|---|---|
status | on_track, at_risk, overdue, fulfilled |
is_recurring | recurrence_day != null |
needs_escalation | не исполнено и до plan_date осталось <= 3 дней (включая просрочку) |
days_to_plan | дней до плановой даты; отрицательное — просрочка |
Статусы
Статус вычисляется в status.go (ComputeStatus) по календарным дням (время отбрасывается):
| Статус | Условие |
|---|---|
fulfilled | fact_date заполнена — вне зависимости от сроков |
overdue | plan_date < сегодня |
at_risk | plan_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):
- Позиция считается «только что исполненной», если до обновления
fact_dateбыл пуст, а после — заполнен. - Если у неё есть
recurrence_day, репозиторий в одной транзакции закрывает её и создаёт копию на следующий период (FulfilRecurring) сparent_id= текущая позиция иfact_date = null. Если следующий период уже был создан (позицию переоткрыли и исполнили снова), второй не создаётся. Два одновременных исполнения не проходят оба: второе получит409 obligations_already_fulfilled. - Следующая дата —
NextOccurrence(plan_date, recurrence_day): следующий месяц относительно плановой даты (не относительноfact_date), число =recurrence_day. Если в месяце нет такого числа (31-е в феврале), берётся последний день месяца — платёж не должен пропадать. - Закрытая позиция остаётся в реестре как история периода; цепочку можно восстановить по
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_DIR | obligation.escalation |
Эскалации рассылает воркер (obligations.EscalationWorker, процесс notifications_worker) раз в час; ключ дедупликации — позиция, плановая дата и получатель, поэтому по одной позиции уходит одна эскалация на срок.
Права
Объекты матрицы прав (Матрица прав RMS): разовые обязательства — obligations, постоянные платежи — recurring_payments. Проверка идёт в handler (requireRead / requireWrite), маршруты закрыты только auth middleware.
| Роль | obligations | recurring_payments |
|---|---|---|
OWNER, FIN_DIR, FIN_ASSIST | F | F |
OPS | R | R |
LEGAL | R | - |
| остальные | - | - |
Какой объект проверяется:
GET /obligations— по queryrecurring=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/obligations | read | Реестр со сводкой. Query: project_id, responsible_user_id, direction, status, recurring=true, open=true, limit, offset |
POST | /api/v1/obligations | write | Создать позицию |
GET | /api/v1/obligations/:id | read | Карточка |
PUT | /api/v1/obligations/:id | write | Обновить; проставление fact_date у постоянного платежа создаёт следующий период |
DELETE | /api/v1/obligations/:id | write | Удалить |
Тело 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):
{
"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); суммы в сводке — тоже числа.