База данных: что хранится и как устроено
Актуально на: 2026-02-27
Проект использует PostgreSQL и Prisma ORM. Основная схема описана в prisma/schema.prisma.
Обновление 2026-02-27
- Для persisted state проекта добавлено поле:
projectCalc— JSON-блок с параметрамиПроект → Настройка расчета:simpleMonthly,simpleQuarterly,simpleHalfYear,simpleYearlycompoundMonthly,compoundQuarterly,compoundHalfYear,compoundYearly
- Это изменение не требует новой SQL-миграции: хранится в общем JSON-снимке workspace state.
Проверка БД (2026-02-27, runtime)
- Подключение к БД:
SELECT 1— OK - Проверка чтения ключевых таблиц (COUNT):
Project: 14ProjectInfo: 7ProjectSettings: 7ProjectProduct: 16ProjectCalendarNode: 15ProjectWorkspaceState: 7ProjectGeneralExpense: 32ProjectGeneralExpenseScheduleItem: 10ProjectPersonnelEmployee: 14
Обновление 2026-02-13
См. CHANGELOG.md. Добавлена настройка “Главная вкладка” (defaultWorkspaceViewId) — хранится в UserUiSettings.settings (JSON).
Обновление 2026-02-16
См. CHANGELOG.md. Раздел «Операционный план → План по персоналу» переведён на нормализованное хранение в БД.
Добавлены таблицы:
ProjectPersonnelSettingsProjectPersonnelSettingsPayoutItemProjectPersonnelEmployeeProjectPersonnelEmployeePayoutItem
Важно:
- Автосейв проекта пишет данные персонала в нормализованные таблицы.
- Legacy JSON (
ProjectWorkspaceState.state) продолжает сохраняться как fallback/бэкап для совместимости.
Обновление 2026-02-20
См. CHANGELOG.md. Раздел «Операционный план → Общие издержки» переведён на нормализованное хранение в БД.
Добавлены таблицы:
ProjectGeneralExpenseSettingsProjectGeneralExpenseProjectGeneralExpenseScheduleItem
Важно:
- Чтение/сборка
generalExpensesPlanв workspace теперь идёт из нормализованных таблиц. - В режиме сохранения
normalize: "full"автосейв пишет общие издержки и графики выплат в нормализованные таблицы. ProjectWorkspaceState.stateсохраняется как fallback/бэкап.- Кнопка
Заполнить шаблонудалена из UI; демо-набор общих издержек теперь сидится сервером/миграцией.
Обновление 2026-02-23
См. CHANGELOG.md. Зафиксирован шаг по таблицам/графикам выплат (персонал + общие издержки) и проверена консистентность хранения пользовательских данных.
Что важно по данным:
- Все бизнес-поля, которые пользователь вводит в разделах персонал и общие издержки, сохраняются в нормализованные таблицы.
- Legacy JSON (
ProjectWorkspaceState.state) остаётся как backstop и источник совместимости, но не является единственным источником для этих разделов. - Демо-проекты заполняются общими издержками через:
- серверные шаблоны (
lib/project-template-seeds.ts); - миграцию backfill для уже существующих demo-проектов (
prisma/migrations/20260220110000_project_general_expenses_normalization/migration.sql).
- серверные шаблоны (
Карта полей UI → БД (прямой SQL-доступ)
| Раздел UI | Куда пишется |
|---|---|
| Общие издержки: настройки (индексация по умолчанию, lag) | ProjectGeneralExpenseSettings |
| Общие издержки: карточка статьи (группа, название, сумма, частота, даты, НДС, количество, примечание, индексация, lag) | ProjectGeneralExpense |
Общие издержки: график выплат статьи (schedule) | ProjectGeneralExpenseScheduleItem |
| Персонал: настройки выплат по проекту | ProjectPersonnelSettings |
| Персонал: дефолтный payout plan | ProjectPersonnelSettingsPayoutItem |
| Персонал: карточки сотрудников (группа, роль, ставка, оклад, дата найма, параметры выплат) | ProjectPersonnelEmployee |
| Персонал: payout plan по сотруднику | ProjectPersonnelEmployeePayoutItem |
Быстрая проверка SQL (что данные читаются напрямую)
Общие издержки по проекту:
SELECT ge."groupName", ge."name", ge."amount", ge."frequency", ge."paymentTiming"
FROM "ProjectGeneralExpense" ge
WHERE ge."projectId" = 'PROJECT_ID_HERE'
ORDER BY ge."sortIndex";График выплат по статьям:
SELECT s."expenseId", s."date", s."mode", s."value"
FROM "ProjectGeneralExpenseScheduleItem" s
WHERE s."projectId" = 'PROJECT_ID_HERE'
ORDER BY s."expenseId", s."sortIndex";Сотрудники по проекту:
SELECT e."groupName", e."roleName", e."headcount", e."salary", e."paymentFrequency"
FROM "ProjectPersonnelEmployee" e
WHERE e."projectId" = 'PROJECT_ID_HERE'
ORDER BY e."sortIndex";Подключение
- Источник подключения: переменная окружения
DATABASE_URL(см..env/.env.example). - Провайдер Prisma:
postgresql. - Все таблицы создаются/обновляются Prisma-миграциями (
prisma/migrations) командойnpx prisma migrate deploy.
Как посмотреть данные:
- UI:
npx prisma studio - Консоль:
psql "<DATABASE_URL>"(или используйте pgAdmin/DBeaver)
Сущности приложения (Prisma модели)
Ниже перечислены таблицы, которые относятся к данным приложения. В Postgres Prisma обычно создаёт таблицы с именами, совпадающими с названиями моделей (например, "User", "Project").
User — пользователь
Назначение: профиль пользователя и данные для входа.
Основные поля:
id(PK,String,cuid())email(уникальный, может бытьnull— зависит от провайдера/режима регистрации)passwordHash(для логина по email/паролю через Credentials)name,firstName,lastName,imagecompany,position,phonecreatedAt,updatedAt
Связи:
accounts[],sessions[]— данные NextAuthworkspaceMemberships[]— участие в рабочих пространствахuiSettings— персональные UI-настройки
UserUiSettings — настройки интерфейса пользователя
Назначение: хранение UI-настроек (тема, фон, акцент, отступы и т.п.) на уровне пользователя.
Поля:
userId(PK, FK →User.id, каскадное удаление)settings(Json, обычноjsonb)createdAt,updatedAt
Где используется:
- чтение/запись через
app/(app)/settings/actions.ts(loadUserUiSettings,saveUserUiSettings).
Примечание:
settings.defaultWorkspaceViewId— какая вкладка workspace открывается первой (пусто = первая видимая).
Workspace — рабочее пространство
Назначение: контейнер для проектов и участников.
Поля:
id(PK)namecreatedAt,updatedAt
Связи:
members[](WorkspaceMember)projects[](Project)
WorkspaceMember — участник рабочего пространства
Назначение: связь пользователь ↔ workspace + роль.
Поля:
id(PK)workspaceId(FK →Workspace.id)userId(FK →User.id)role(WorkspaceRole, по умолчаниюMEMBER)createdAt
Ограничения/индексы:
- уникальность:
@@unique([workspaceId, userId]) - индексы: по
userId,workspaceId
Примечание: surrogate id важен для совместимости с Django Admin (см. django_admin/README.md).
Project — проект
Назначение: карточка проекта + статус/прогресс. “Сущности проекта” (проектная информация, настройки, продукты, календарный план) хранятся в нормализованных таблицах (вариант B).
Поля:
id(PK)workspaceId(FK →Workspace.id)name(отображаемое название)description(опционально)status(ProjectStatus:DRAFT/ACTIVE/ARCHIVED)progressPct(int, 0..100 условно)archivedAt(nullable)createdAt,updatedAt
Связи:
workspaceworkspaceState(ProjectWorkspaceState, legacy/fallback)info(ProjectInfo)settings(ProjectSettings)products[](ProjectProduct)calendarNodes[](ProjectCalendarNode)
Где используется:
- создание/архивация/переименование/удаление:
app/(app)/projects/actions.ts.
ProjectWorkspaceState — legacy JSON-снимок (устаревающее)
Назначение: исторически хранил всё состояние “PlanForge/Workspace UI” проекта одним JSON-блобом.
Начиная с перехода на вариант B (нормализация), эта таблица используется как источник для бэкапа/бэкфилла и (при необходимости) как fallback для чтения старых проектов. Запись новых данных идёт в нормализованные таблицы.
Поля:
projectId(PK, FK →Project.id, каскадное удаление)state(Json, обычноjsonb)createdAt,updatedAt
Где используется:
- fallback-чтение (если нормализованных данных ещё нет):
lib/project-workspace-store.ts(используется изapp/(workspace)/projects/[projectId]/actions.ts). - первичная загрузка на страницу:
app/(workspace)/projects/[projectId]/page.tsx.
Примечание про содержимое state:
- Структура
stateне фиксирована Prisma-схемой (типJson), но заполняется фронтендом. - В демо-шаблоне (
createProjectWithTemplate(..., "demo")вapp/(app)/projects/actions.ts) внутрьstateкладутся блоки вида:projectInfo(title/company/variant/industry/author/region/startDate/durationMonths/scenario)settings(currency, themeId/bgColor/accentColor/paddingLeftPct/paddingRightPct/isHoverFxEnabled)products[](name/unit/price/currency/status/note)calendarPhases[](этапы, подэтапы, даты, стоимость, график оплат)
Дополнительная логика:
saveProjectWorkspaceStateпытается синхронизироватьProject.name, если вstate.projectInfo.titleизменилось название.
Нормализованные данные проекта (вариант B)
В варианте B “сущности проекта” хранятся в отдельных таблицах, чтобы можно было делать простые агрегаты (SUM/AVG/GROUP BY) по проекту.
Если у вас уже есть данные в legacy ProjectWorkspaceState.state, их можно перенести в новые таблицы скриптом backfill_project_state.js (см. RUN.md).
ProjectInfo — паспорт проекта
Поля (основные):
projectId(PK, FK →Project.id)title,company,variant,industry,author,region,scenariostartDate(Date),durationMonths(Int)
ProjectSettings — настройки проекта (валюта + визуальные настройки workspace)
Поля (основные):
projectId(PK, FK →Project.id)projectCurrencyType,projectCurrencyName,projectCurrencyCodethemeId,bgColor,accentColor,paddingLeftPct,paddingRightPct,isHoverFxEnabled
ProjectProduct — продукты проекта
Поля (основные):
id(PK)projectId(FK →Project.id)sortIndex(для стабильного порядка в UI)name,unit,status,notecurrencyType,currencyName,currencyCodeprice(Decimal, nullable)
Пример аналитики:
- средняя цена продукта в проекте:
AVG(price)поProjectProduct(гдеprice is not null).
ProjectProductSalesVolume — плановые объёмы сбыта по периодам (нормализация)
Назначение: хранение плана сбыта по объёмам в разрезе проекта/продукта/месяца, чтобы считать агрегаты напрямую SQL‑запросами (объёмы, выручка и т.п.).
Поля:
projectId(часть PK, FK →Project.id)productId(часть PK, FK →ProjectProduct.id)periodIndex(часть PK,Int) — индекс периода от даты начала проекта (0..N-1)date(Date, первое число месяца) — календарная дата периодаvolume(Decimal(18,4)) — плановый объёмcreatedAt,updatedAt
Индексы:
- PK:
projectId + productId + periodIndex projectIdprojectId + dateproductId
Где формируется:
- UI: Операционный план → План сбыта → Объёмы сбыта (график/таблица).
- Запись/чтение:
lib/project-workspace-store.ts(обновление идёт вместе с автосейвом проекта).
Важно:
- Если таблица ещё не создана миграцией, приложение сохраняет объёмы в JSON‑снимок
ProjectWorkspaceState.stateи поднимает их оттуда при загрузке.
ProjectPersonnelSettings — настройки выплат по проекту (дефолт)
Назначение: базовые настройки раздела персонала на уровне проекта.
Поля:
projectId(PK, FK →Project.id)paymentFrequency(monthly/quarterly)payrollTaxRate(Decimal(9,4))annualIndexationRate(Decimal(9,4))paymentTiming(start/end)paymentLagDays(Int)createdAt,updatedAt
ProjectPersonnelSettingsPayoutItem — дефолтный график выплат проекта
Назначение: дефолтные правила выплат, которые применяются при создании/редактировании сотрудников.
Поля:
projectId(часть PK, FK →ProjectPersonnelSettings.projectId)id(часть PK, id правила из UI)sortIndexdateType(day/eom)dayOfMonth(Int)mode(amount/percent)value(Decimal(18,4))createdAt,updatedAt
ProjectPersonnelEmployee — должности/позиции персонала проекта
Назначение: хранение карточек сотрудников (по факту — позиций с количеством ставок).
Поля:
projectId(часть PK, FK →Project.id)id(часть PK, id сотрудника из UI)sortIndexgroupName(группа: производство, управление и т.д.)roleName(должность)headcount(Decimal(12,4)) — количество ставокsalary(Decimal(18,4)) — оклад за месяцhireDate(Date, nullable)note- персональные параметры выплат:
paymentFrequency,payrollTaxRate,annualIndexationRate,paymentTiming,paymentLagDays createdAt,updatedAt
ProjectPersonnelEmployeePayoutItem — график выплат по сотруднику
Назначение: набор правил выплат для конкретной позиции/сотрудника.
Поля:
projectId(часть PK)employeeId(часть PK, FK →ProjectPersonnelEmployee.id)id(часть PK, id правила)sortIndexdateType(day/eom)dayOfMonth(Int)mode(amount/percent)value(Decimal(18,4))createdAt,updatedAt
ProjectGeneralExpenseSettings — дефолтные настройки общих издержек проекта
Назначение: общие настройки расчёта издержек на уровне проекта.
Поля:
projectId(PK, FK →Project.id)annualIndexationRate(Decimal(9,4))defaultPaymentLagDays(Int)createdAt,updatedAt
ProjectGeneralExpense — статьи общих издержек проекта
Назначение: хранение карточек издержек с параметрами расчёта и оплаты.
Поля:
projectId(часть PK, FK →Project.id)id(часть PK, id статьи из UI)sortIndexgroupName,name,noteamount(Decimal(18,4))startDate,endDate(Date, nullable)frequency(monthly/quarterly/yearly/one_time)paymentTiming(start/end/schedule)vatRate(Decimal(9,4), nullable)isIndexed(Boolean)annualIndexationRate(Decimal(9,4))paymentLagDays(Int)quantity(Decimal(12,4))createdAt,updatedAt
ProjectGeneralExpenseScheduleItem — график выплат по статье издержек
Назначение: набор элементов графика выплат для статьи с режимом schedule.
Поля:
projectId(часть PK)expenseId(часть PK, FK →ProjectGeneralExpense.id)id(часть PK, id элемента графика)sortIndexdate(Date)mode(amount/percent)value(Decimal(18,4))createdAt,updatedAt
Премии персонала (bonus rules)
Важно: на текущем этапе премии (personnelPlan.settings.bonuses и personnelPlan.settingsByEmployeeId[*].bonuses) сохраняются в ProjectWorkspaceState.state (JSON), чтобы не теряться при перезагрузке и быть доступными для запросов.
Это позволяет:
- хранить все параметры премий (наименование, триггер, режим, значение, индекс, показатель, период действия);
- делать SQL-запросы по JSON-полям без отдельной миграции таблиц премий.
Пример: список премий по сотрудникам проекта
SELECT
p."projectId",
e.key AS employee_id,
b->>'id' AS bonus_id,
b->>'name' AS bonus_name,
b->>'trigger' AS trigger,
b->>'mode' AS mode,
b->>'value' AS value,
b->>'metric' AS metric,
b->>'indexed' AS indexed,
b->>'startDate' AS start_date,
b->>'endDate' AS end_date
FROM "ProjectWorkspaceState" p
CROSS JOIN LATERAL jsonb_each(COALESCE(p.state->'personnelPlan'->'settingsByEmployeeId', '{}'::jsonb)) e
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.value->'bonuses', '[]'::jsonb)) b
WHERE p."projectId" = 'PROJECT_ID_HERE'
ORDER BY employee_id, bonus_name;Пример: только премии “% от выручки”
SELECT
p."projectId",
e.key AS employee_id,
b->>'name' AS bonus_name,
b->>'value' AS percent_value
FROM "ProjectWorkspaceState" p
CROSS JOIN LATERAL jsonb_each(COALESCE(p.state->'personnelPlan'->'settingsByEmployeeId', '{}'::jsonb)) e
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(e.value->'bonuses', '[]'::jsonb)) b
WHERE p."projectId" = 'PROJECT_ID_HERE'
AND b->>'mode' = 'percent_metric'
AND COALESCE(b->>'metric', '') = 'revenue';ProjectCalendarNode — узлы календарного плана (этапы/подэтапы)
Хранит дерево этапов одним списком с parentId.
Поля (основные):
projectId(FK →Project.id)id(id из UI; PK составной:projectId + id, напримерphase-<uuid>)parentId(nullable)sortIndextitle,longTitlestartDate,endDate(Date)cost(Decimal, nullable)paymentType(start/end/schedule)
Пример аналитики:
- общий бюджет календаря:
SUM(cost)поProjectCalendarNode(гдеcost is not null).
ProjectCalendarPaymentScheduleItem — элементы графика оплат
Поля (основные):
calendarNodeProjectId(FK часть →ProjectCalendarNode.projectId)calendarNodeId(FK часть →ProjectCalendarNode.id)id(id из UI; PK составной:calendarNodeProjectId + calendarNodeId + id)sortIndexdate(Date)mode(amount/percent)value(Decimal, nullable)
Примеры запросов (SQL)
Примеры ниже рассчитаны на Postgres и Prisma-таблицы (имена таблиц в кавычках, как создаёт Prisma).
Список проектов
SELECT "id", "name", "status", "createdAt"
FROM "Project"
ORDER BY "createdAt" DESC
LIMIT 50;Общая сумма затрат по календарному плану по всем проектам
Считается по всем узлам календаря (этапы + подэтапы), т.к. они все в "ProjectCalendarNode".
SELECT
p."id",
p."name",
COALESCE(SUM(n."cost"), 0) AS total_calendar_cost
FROM "Project" p
LEFT JOIN "ProjectCalendarNode" n ON n."projectId" = p."id"
GROUP BY p."id", p."name"
ORDER BY total_calendar_cost DESC;Быстрая проверка “почему я не вижу календарь в БД?”
- Есть ли вообще узлы календаря в нормализованной таблице:
SELECT COUNT(*) AS nodes FROM "ProjectCalendarNode";- Какие проекты без календарных узлов:
SELECT p."id", p."name"
FROM "Project" p
LEFT JOIN "ProjectCalendarNode" n ON n."projectId" = p."id"
GROUP BY p."id", p."name"
HAVING COUNT(n."id") = 0
ORDER BY p."createdAt" DESC;- Есть ли legacy JSON (и потенциально данные для восстановления):
SELECT COUNT(*) AS legacy_rows FROM "ProjectWorkspaceState";Если ProjectCalendarNode пустая, но ProjectWorkspaceState не пустая — выполните node audit_project_data.js --fix (см. RUN.md).
Только верхнеуровневые этапы (без подэтапов):
SELECT
p."id",
p."name",
COALESCE(SUM(n."cost"), 0) AS total_calendar_cost
FROM "Project" p
LEFT JOIN "ProjectCalendarNode" n
ON n."projectId" = p."id" AND n."parentId" IS NULL
GROUP BY p."id", p."name"
ORDER BY total_calendar_cost DESC;Средняя цена продуктов в конкретном проекте
SELECT
p."id",
p."name",
AVG(pp."price") AS avg_price,
COUNT(pp."price") AS priced_products,
COUNT(*) AS total_products
FROM "Project" p
JOIN "ProjectProduct" pp ON pp."projectId" = p."id"
WHERE p."id" = 'PROJECT_ID_HERE'
GROUP BY p."id", p."name";Средняя цена по валюте (пример: только RUB):
SELECT AVG(pp."price") AS avg_price_rub
FROM "ProjectProduct" pp
WHERE pp."projectId" = 'PROJECT_ID_HERE'
AND pp."currencyCode" = 'RUB'
AND pp."price" IS NOT NULL;Сумма затрат по календарю конкретного проекта + количество узлов
SELECT
n."projectId",
COUNT(*) AS nodes,
COALESCE(SUM(n."cost"), 0) AS total_calendar_cost
FROM "ProjectCalendarNode" n
WHERE n."projectId" = 'PROJECT_ID_HERE'
GROUP BY n."projectId";Просмотр графика оплат (payment schedule) по узлам календаря проекта
SELECT
n."projectId",
n."id" AS node_id,
n."title",
i."date",
i."mode",
i."value"
FROM "ProjectCalendarNode" n
JOIN "ProjectCalendarPaymentScheduleItem" i
ON i."calendarNodeProjectId" = n."projectId"
AND i."calendarNodeId" = n."id"
WHERE n."projectId" = 'PROJECT_ID_HERE'
ORDER BY n."sortIndex" ASC, i."sortIndex" ASC;План сбыта: объёмы по месяцам (сумма по всем продуктам)
SELECT
v."date",
SUM(v."volume") AS total_volume
FROM "ProjectProductSalesVolume" v
WHERE v."projectId" = 'PROJECT_ID_HERE'
GROUP BY v."date"
ORDER BY v."date";План сбыта: объёмы по продуктам по периодам
SELECT
p."sortIndex",
p."name" AS product,
v."periodIndex",
v."date",
v."volume"
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
ORDER BY p."sortIndex" ASC, v."periodIndex" ASC;План сбыта: плановая выручка (volume * price) по месяцам
Если в проекте несколько валют по продуктам — группируем по currencyCode.
SELECT
v."date",
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
GROUP BY v."date", p."currencyCode"
ORDER BY v."date", p."currencyCode";Выручка: примеры SQL (разные варианты)
Примечания:
- Запросы ниже считают выручку из нормализованных таблиц (
ProjectProduct+ProjectProductSalesVolume). - Если в UI включены “переопределения цен по периодам” (legacy JSON в
ProjectWorkspaceState.state), то эти переопределения не лежат в нормализованных таблицах и в SQL ниже не учитываются. - Если в проекте используются разные валюты по продуктам — всегда группируйте по
currencyCode.
1) Выручка по проекту за период (по месяцам)
SELECT
v."date" AS period_date,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
AND v."date" >= DATE '2026-01-01'
AND v."date" < DATE '2027-01-01'
GROUP BY v."date", p."currencyCode"
ORDER BY v."date", p."currencyCode";2) Выручка по проекту за период (по кварталам / по годам)
Кварталы:
SELECT
date_trunc('quarter', v."date")::date AS period_quarter,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
GROUP BY 1, 2
ORDER BY 1, 2;Годы:
SELECT
date_trunc('year', v."date")::date AS period_year,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
GROUP BY 1, 2
ORDER BY 1, 2;3) Выручка по продуктам за период (топ / разрез)
SELECT
p."id" AS product_id,
p."sortIndex",
p."name" AS product,
p."salesChannel",
p."unit",
p."currencyCode",
SUM(v."volume") AS total_volume,
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
AND v."date" BETWEEN DATE '2026-01-01' AND DATE '2026-12-31'
GROUP BY p."id", p."sortIndex", p."name", p."salesChannel", p."unit", p."currencyCode"
ORDER BY revenue DESC NULLS LAST;4) Доля продукта в выручке проекта за период (share)
WITH by_product AS (
SELECT
p."id" AS product_id,
p."name" AS product,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
AND v."date" >= DATE '2026-01-01'
AND v."date" < DATE '2027-01-01'
GROUP BY p."id", p."name", p."currencyCode"
),
totals AS (
SELECT "currencyCode", SUM(revenue) AS total_revenue
FROM by_product
GROUP BY "currencyCode"
)
SELECT
b.product_id,
b.product,
b."currencyCode",
b.revenue,
CASE WHEN t.total_revenue > 0 THEN (b.revenue / t.total_revenue) * 100 ELSE 0 END AS share_pct
FROM by_product b
JOIN totals t ON t."currencyCode" = b."currencyCode"
ORDER BY b."currencyCode", b.revenue DESC;5) Выручка по каналу продаж (salesChannel) за период
SELECT
COALESCE(NULLIF(p."salesChannel", ''), '—') AS sales_channel,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
AND v."date" >= DATE '2026-01-01'
AND v."date" < DATE '2027-01-01'
GROUP BY 1, 2
ORDER BY 3 DESC NULLS LAST;6) Выручка по предприятию (Workspace) по всем проектам (по месяцам)
Если “предприятие” = Workspace, то можно суммировать по всем проектам workspace.
SELECT
w."id" AS workspace_id,
w."name" AS workspace,
v."date" AS period_date,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "Workspace" w
JOIN "Project" pr ON pr."workspaceId" = w."id"
JOIN "ProjectProduct" p ON p."projectId" = pr."id"
JOIN "ProjectProductSalesVolume" v ON v."projectId" = pr."id" AND v."productId" = p."id"
WHERE w."id" = 'WORKSPACE_ID_HERE'
GROUP BY 1, 2, 3, 4
ORDER BY 3, 4;7) Выручка по группе проектов (несколько проектов)
SELECT
v."date" AS period_date,
p."currencyCode",
SUM(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" IN ('PROJECT_ID_1', 'PROJECT_ID_2', 'PROJECT_ID_3')
GROUP BY 1, 2
ORDER BY 1, 2;8) Выручка по продукту по периодам (таймлайн одного продукта)
SELECT
v."date" AS period_date,
p."currencyCode",
v."volume",
p."price",
(v."volume" * p."price") AS revenue
FROM "ProjectProductSalesVolume" v
JOIN "ProjectProduct" p ON p."id" = v."productId"
WHERE v."projectId" = 'PROJECT_ID_HERE'
AND v."productId" = 'PRODUCT_ID_HERE'
ORDER BY v."date";Персонал: примеры SQL (новые нормализованные таблицы)
Ниже запросы для задач, которые раньше было сложно делать из JSON.
1) Сколько сотрудников (ставок) планируется по проекту в каждом периоде
WITH project_ctx AS (
SELECT
pi."projectId",
pi."startDate"::date AS start_date,
GREATEST(1, LEAST(239, COALESCE(pi."durationMonths", 0))) AS duration_months
FROM "ProjectInfo" pi
WHERE pi."projectId" = 'PROJECT_ID_HERE'
),
months AS (
SELECT
gs AS period_index,
(pc.start_date + make_interval(months => gs))::date AS period_date
FROM project_ctx pc
CROSS JOIN generate_series(0, (SELECT duration_months FROM project_ctx)) AS gs
)
SELECT
m.period_index,
to_char(m.period_date, 'MM.YYYY') AS period_label,
COALESCE(SUM(
CASE
WHEN e."hireDate" IS NULL OR e."hireDate" <= m.period_date THEN e."headcount"
ELSE 0
END
), 0) AS total_headcount
FROM months m
LEFT JOIN "ProjectPersonnelEmployee" e
ON e."projectId" = 'PROJECT_ID_HERE'
GROUP BY m.period_index, m.period_date
ORDER BY m.period_index;2) Сколько должностей и ставок в разрезе групп
SELECT
e."groupName",
COUNT(*) AS positions_count,
COALESCE(SUM(e."headcount"), 0) AS headcount_total
FROM "ProjectPersonnelEmployee" e
WHERE e."projectId" = 'PROJECT_ID_HERE'
GROUP BY e."groupName"
ORDER BY e."groupName";3) По каждому сотруднику на каждую дату — сколько запланировано к выплате
Логика совпадает с UI: учёт частоты выплат, индексации и правил выплат (percent/amount, day/eom).
WITH project_ctx AS (
SELECT
pi."projectId",
pi."startDate"::date AS start_date,
GREATEST(1, LEAST(239, COALESCE(pi."durationMonths", 0))) AS duration_months
FROM "ProjectInfo" pi
WHERE pi."projectId" = 'PROJECT_ID_HERE'
),
months AS (
SELECT
gs AS period_index,
(pc.start_date + make_interval(months => gs))::date AS period_date
FROM project_ctx pc
CROSS JOIN generate_series(0, (SELECT duration_months FROM project_ctx)) AS gs
),
accrual AS (
SELECT
e."id" AS employee_id,
e."groupName",
e."roleName",
m.period_index,
m.period_date,
CASE
WHEN e."hireDate" IS NOT NULL AND e."hireDate" > m.period_date THEN 0::numeric
WHEN e."paymentFrequency" = 'quarterly' THEN
CASE
WHEN ((m.period_index + 1) % 3) = 0
THEN (e."salary" * e."headcount")
* POWER(1 + (e."annualIndexationRate" / 100), FLOOR(m.period_index / 12.0))
* 3
ELSE 0::numeric
END
ELSE
(e."salary" * e."headcount")
* POWER(1 + (e."annualIndexationRate" / 100), FLOOR(m.period_index / 12.0))
END AS accrued_amount
FROM "ProjectPersonnelEmployee" e
CROSS JOIN months m
WHERE e."projectId" = 'PROJECT_ID_HERE'
),
payments AS (
SELECT
a.employee_id,
a."groupName",
a."roleName",
a.period_index,
a.period_date,
p."id" AS payout_rule_id,
CASE
WHEN p."dateType" = 'eom'
THEN (date_trunc('month', a.period_date) + interval '1 month - 1 day')::date
ELSE (
date_trunc('month', a.period_date)::date
+ (LEAST(
p."dayOfMonth",
EXTRACT(day FROM (date_trunc('month', a.period_date) + interval '1 month - 1 day'))::int
) - 1) * interval '1 day'
)::date
END AS payment_date,
CASE
WHEN p."mode" = 'percent' THEN a.accrued_amount * (p."value" / 100)
ELSE p."value"
END AS payment_amount
FROM accrual a
JOIN "ProjectPersonnelEmployeePayoutItem" p
ON p."projectId" = 'PROJECT_ID_HERE'
AND p."employeeId" = a.employee_id
)
SELECT
employee_id,
"groupName",
"roleName",
period_index,
payment_date,
SUM(payment_amount) AS amount
FROM payments
GROUP BY employee_id, "groupName", "roleName", period_index, payment_date
ORDER BY employee_id, payment_date, period_index;Примеры запросов (Prisma)
Сумма затрат по календарю в разрезе проектов:
const totals = await prisma.projectCalendarNode.groupBy({
by: ["projectId"],
_sum: { cost: true },
});Средняя цена продуктов по проекту:
const avg = await prisma.projectProduct.aggregate({
where: { projectId: "PROJECT_ID_HERE", price: { not: null } },
_avg: { price: true },
});Аутентификация (NextAuth + PrismaAdapter)
Файл конфигурации: auth.ts.
Account — привязка OAuth-аккаунта
Назначение: связь пользователя с внешним провайдером (Google/Yandex/VK/Mail.ru) и токены.
Ключевые поля:
userId(FK →User.id)provider,providerAccountIdaccess_token,refresh_token,expires_at,id_token,scope, …
Ограничение:
- уникальность:
@@unique([provider, providerAccountId])
Session — сессии NextAuth
Поля:
sessionToken(уникальный)userId(FK →User.id)expires
Примечание: в auth.ts выбран session: { strategy: "jwt" }, поэтому таблица Session может использоваться минимально или не использоваться вообще (зависит от режима и адаптера).
VerificationToken — токены верификации
Назначение: токены для подтверждений/магических ссылок и т.п. (механизм NextAuth).
Поля:
identifiertoken(уникальный)expires
Служебные таблицы
Prisma
_prisma_migrations— журнал применённых миграций Prisma.
Django Admin (если вы запускали python django_admin/manage.py migrate)
Django создаст служебные таблицы, не связанные напрямую с Prisma-моделями приложения, например:
django_migrationsdjango_content_typeauth_user,auth_group,auth_permission,auth_group_permissions,auth_user_groups,auth_user_user_permissionsdjango_admin_logdjango_session
Это отдельная аутентификация для входа в админку Django (см. django_admin/README.md).
Где в коде происходит запись данных
- Проекты (создание/удаление/архив/переименование) —
app/(app)/projects/actions.ts. - Основные данные проекта (нормализованные таблицы + сборка состояния для UI) —
app/(workspace)/projects/[projectId]/actions.tsиlib/project-workspace-store.ts. - UI-настройки пользователя (JSON) —
app/(app)/settings/actions.ts. - Пользователи/аккаунты OAuth/токены — через NextAuth + PrismaAdapter (
auth.ts).