Система бюджета и сметы проекта
Сейчас
План проекта — смета (модуль estimates), факт — журнал расходов; сравнение по разделам — План и факт. Ниже — устройство журнала расходов (cost_items) и прежней сводки бюджета по категориям.
Это описание того, как CRM RMS показывает экономику проекта: от отдельных статей затрат до сметы с наценкой, финансовой сводки и документов.
Общая схема
В текущей версии backend бюджетный контур разделен на два слоя:
/api/v1/finance/...— источник финансовых строк проекта:cost_items, выручка и P&L-сводка./api/v1/projects/:id/budget— агрегированная смета проекта: строки по категориям, subtotal, наценка и итог.
budget не обходит задачи, логистику и командировки напрямую. Он читает finance.cost_items, группирует их по категориям и применяет настройки проекта из project_budget_settings.
Логистика: "Подтверждена доставка на 15 000"
↓
Finance: "Создаю/обновляю cost item source_type=logistic_operation"
↓
Командировка: "План/факт поездки 8 000"
↓
Finance: "Создаю/обновляю cost item source_type=trip"
↓
Менеджер: "Добавляю ручную статью подрядчика"
↓
Finance: "Создаю cost item без source_type"
↓
Budget: "Группирую cost items по категориям и считаю наценку"
↓
Documents: "Генерирую estimate/invoice/act из проекта, бюджета и реквизитов"Статья затрат (CostItem)
CostItem — атомарная финансовая строка проекта.
| Поле | Смысл |
|---|---|
project_id | Проект, к которому относится затрата |
category | Категория из фиксированного списка |
planned_amount | Плановая сумма |
actual_amount | Фактическая сумма |
source_type / source_id | Доменный источник, если строка создана автоматически |
notes | Пояснение для ручной или уточненной строки |
Категории, поддержанные backend:
| Category | Что обычно означает |
|---|---|
tech | Техника, оборудование, материалы |
logistics | Доставка и логистические операции |
travel | Командировки |
labor | Трудозатраты |
rent | Аренда |
salary | Зарплата и ФОТ |
contractors | Подрядчики |
materials | Материалы |
creative | Креатив и производство контента |
office | Офисные расходы |
other | Прочее |
taxes | Налоги |
Сервис Finance проверяет суммы:
- суммы не могут быть отрицательными;
- в ручной статье план не может быть меньше факта; автоматические источники (командировки, логистика, часы) записывают факт как есть — перерасход виден как отклонение;
- для проекта в статусе
closedновые/обновленные cost items запрещены.
Источники затрат
Ручные статьи
Менеджер или финансовый пользователь создает строку напрямую:
POST /api/v1/finance/cost-items;GET /api/v1/finance/cost-items?project_id=...;GET /api/v1/finance/projects/:id/cost-items;GET /api/v1/finance/projects/:id/cost-items/export.
Ручной cost item подходит для подрядчиков, аренды, материалов, налогов и любых затрат, которые пока не приходят из доменного workflow.
Правка — PATCH /api/v1/finance/cost-items/:id: отсутствующее поле не меняется, явный null очищает его (planned_amount, actual_amount, notes → NULL); пустое название тоже стирает заметку. Статья без плана — просто фактический расход (как при создании без planned_amount), правило «план ≥ факт» проверяется по итоговым значениям. Суммы принимаются с копейками.
Логистика
Логистическая операция пишет в Finance как:
category=logistics
source_type=logistic_operation
source_id=<operation_id>Запись создается после финансового approve суммы логистики или при закрытии операции, когда известны плановая и фактическая стоимость. Детальный маршрут описан в Логистическом контуре.
Командировки
Командировка пишет в Finance как:
category=travel
source_type=trip
source_id=<trip_id>При создании командировки с проектом сервис сохраняет план/факт, если они есть. При переходе в completed факт обновляется. При cancelled сумма источника обнуляется, чтобы отмененная поездка не раздувала бюджет.
Учет времени (трудозатраты)
Каждая запись времени превращается в статью «Трудозатраты» (finance.SyncLaborCosts, internal/modules/finance/labor.go):
category=labor
source_type=time_entry
source_id=<time_entry_id>
actual_amount = hours × rate (округление до копеек)
planned_amount = NULL
notes = "Трудозатраты: 4.00 ч × 2500.00"Ставка выбирается в порядке приоритета:
| # | Источник | Когда |
|---|---|---|
| 1 | project_team_members.hourly_rate | Проектная ставка участника команды (> 0) |
| 2 | compensation_profiles с kind=hourly | Почасовой профиль оплаты, действующий на дату записи |
| 3 | compensation_profiles с kind=salary | Оклад / норма часов в месяце (APP_LABOR_MONTHLY_NORM_HOURS, по умолчанию 168) |
Без ставки статья не создаётся: такие часы остаются в учёте времени, но не в деньгах.
Когда пересчитывается: после POST /time-entries/self, POST /time-entries, после паузы и завершения рабочей сессии задачи и после утверждения табеля (для всех проектов, где сотрудник отмечал часы в этом месяце). Пересчёт идемпотентен и обновляет только изменившиеся строки. Сбой пересчёта не отменяет запись времени — строки догоняются при следующем изменении.
Плановая сумма у таких строк пустая: плановые трудозатраты должны приходить из оценки ТЗ (этап 7 плана), а не из факта. Закрытый проект (closed) не пересчитывается. Строки с источником — только для чтения: править их через PUT /finance/cost-items/:id нельзя.
Budget summary
GET /api/v1/projects/:id/budget возвращает агрегированную смету проекта.
Сервис делает три шага:
- Читает настройки бюджета проекта (
markup_percent,notes). - Получает все
finance.cost_itemsпроекта. - Группирует суммы по категориям, считает subtotal, наценку, УСН, НДС и
grand_total.
Формат результата:
{
"project_id": 7,
"lines": [
{
"category": "logistics",
"description": "Логистика",
"amount": 15000.0,
"actual_amount": 14200.0
}
],
"subtotal": 15000.0,
"markup_percent": 20,
"markup_amount": 3000.0,
"total": 18000.0,
"display_lines": [
{
"category": "logistics",
"description": "Логистика",
"amount": 15000.0,
"kind": "item"
},
{
"category": "markup",
"description": "Наценка (20.00%)",
"amount": 3000.0,
"kind": "markup"
},
{
"category": "taxes",
"description": "УСН (10.00%)",
"amount": 1800.0,
"kind": "tax",
"tax_type": "usn"
},
{
"category": "taxes",
"description": "НДС (5.00%)",
"amount": 990.0,
"kind": "tax",
"tax_type": "vat"
}
],
"usn_percent": 10,
"usn_amount": 1800.0,
"vat_mode": "on_top",
"vat_rate": 5,
"vat_amount": 990.0,
"taxes": {
"usn": {
"type": "usn",
"display_mode": "separate",
"base_amount": 18000.0,
"amount": 1800.0,
"affects_total": true,
"included_in_lines": false
},
"vat": {
"type": "vat",
"display_mode": "separate",
"base_amount": 19800.0,
"amount": 990.0,
"affects_total": true,
"included_in_lines": false
}
},
"grand_total": 20790.0
}Важно: в текущей реализации subtotal и total строятся по плановым суммам (planned_amount). Фактические суммы отдаются в строках как actual_amount, чтобы UI мог показать план/факт, но они не заменяют плановый subtotal.
lines остаётся сырой группировкой статей затрат. display_lines — представление для UI и проектных документов: наценка добавляется строкой kind: "markup"; если налог скрыт в позициях, суммы строк увеличены пропорционально; если налог показывается отдельно и увеличивает итог, в конец добавляется строка kind: "tax".
Порядок налогового расчёта фиксированный: сумма позиций после markup_percent → УСН → НДС. Например, позиция 1000, УСН 10%, НДС on_top 5% даёт 1000 + 100 + 55 = 1155.
Наценка
Настройки проекта читаются и обновляются через:
GET /api/v1/projects/:id/budget/settings;PUT /api/v1/projects/:id/budget/settings.
Если настройки ещё не сохранялись, GET возвращает 200 с нулевой наценкой и режимами отображения налогов separate. Отсутствие строки настроек не считается ошибкой и не требует предварительного PUT.
Payload:
{
"markup_percent": 20,
"usn_percent": 10,
"usn_display_mode": "separate",
"vat_display_mode": "included",
"notes": "Коммерческая наценка для клиента"
}markup_percent допускает диапазон 0..10000 и хранится с точностью 10 знаков после запятой (migrations/0156): итог сметы, заданный в интерфейсе суммой, пересчитывается в процент без сдвига на рубли. usn_percent — 0..100. Настройки закрытого проекта не меняются: PUT отвечает 409 budget_project_closed. Менять настройки могут пишущие client_budget или costs. Режимы usn_display_mode и vat_display_mode принимают значения:
separate— налог показывается отдельной строкой, если он увеличивает итог;included— налог распределяется по позициям, если это additive-налог (УСН,НДС on_top).
В интерфейсе проекта галочка «Добавить к цене позиций» включает УСН и сохраняет usn_display_mode=included; ставка по умолчанию — 6%, но её можно изменить. Это не меняет сырые cost_items: налог пропорционально, с копеечным округлением, добавляется к display_lines и к строкам детального PDF.
Для vat_mode = included НДС всегда только выделяется из текущей суммы и не увеличивает grand_total; режим отображения управляет тем, показывать ли его отдельно как "в том числе" или считать уже включённым в строки.
Финансовая сводка проекта
GET /api/v1/finance/projects/:id/summary возвращает P&L:
planned_revenue;actual_revenue;planned_costs;actual_costs;planned_profit;actual_profit.
Это отдельная финансовая сводка, не то же самое, что budget summary. Budget нужен для сметы с наценкой, Finance summary — для управленческого контроля прибыли.
Документы и смета
PDF/документ сметы создается через:
POST /api/v1/projects/:id/documents/estimate.
Генератор берет:
- проект и клиента;
- budget summary;
- cost items как источник строк;
- primary internal-реквизиты поставщика;
- primary-реквизиты компании-клиента.
Если реквизиты не заполнены, документ может сгенерироваться с пустыми юридическими строками. Если не удалось прочитать бюджет, cost items или реквизиты, генерация должна остановиться, чтобы не выпустить некорректный документ.
Доступ
Финансовые и бюджетные endpoint-ы проверяют доступ к секции finance проекта.
Доступ есть:
admin;- пользователям с глобальным
finance.manageв рамках доступного проекта; - менеджеру проекта;
- участникам команды с проектными финансовыми ролями:
manager,lead,accountant,financeи поддержанными legacy-алиасами.
Обычный участник команды не получает финансовую секцию только из-за участия в проекте.
Рекомендации для фронта
Экран бюджета
- Загружайте
GET /api/v1/projects/:id/budgetдля агрегированной сметы. - Загружайте
GET /api/v1/finance/projects/:id/cost-itemsдля детализации и редактирования строк. - Разделяйте "плановую смету" и "факт": subtotal строится по плану, фактические суммы показываются отдельно.
- Если в проекте есть time entries, но нет
laborcost items, показывайте часы отдельно от денег или явно сообщайте, что трудовые затраты еще не заведены в Finance. - Для Excel используйте
GET /api/v1/finance/projects/:id/cost-items/export. Колонки совпадают с таблицей «Статьи затрат»: «Статья», «Категория», «Источник», «План, ₽», «Факт, ₽», «Комментарий» — с русскими подписями категорий и источников, а не кодами.
Форма cost item
Проверяйте на клиенте те же правила, что backend:
- category только из списка;
- суммы неотрицательные;
planned_amount >= actual_amount, если обе суммы указаны; пустой план уходит какnull(создание — поле не передаётся), а не как 0;- для закрытого проекта форму создания/редактирования лучше скрыть.
Ключевые правила финансового контура
1. Budget агрегирует Finance
Если строка не попала в finance.cost_items, она не появится в projects/:id/budget.
2. Source защищает от дублей
Для автоматических источников используется пара source_type + source_id. Повторная синхронизация логистики или командировки обновляет существующую строку, а не создает дубль.
3. Closed проект финансово заморожен
Статус проекта closed запрещает новые cost items и изменения источников через Finance.
4. Деньги считаются десятично
Все суммы в API приходят как JSON number, но внутри считаются через money.Money и NUMERIC, без float64 для бизнес-арифметики.
Резюме: как появляется смета
Cost Items
manual / logistic_operation / logistic_operation_supply / trip / time_entry
↓
Finance
planned_amount + actual_amount + category
↓
Budget Summary
lines + subtotal + markup + total
↓
Documents
estimate / invoice / act
↓
Project Control
план, факт, прибыль, закрытие проекта