РазработкаМодель данных

База данных: что хранится и как устроено

Актуально на: 2026-02-27

Проект использует PostgreSQL и Prisma ORM. Основная схема описана в prisma/schema.prisma.

Обновление 2026-02-27

  • Для persisted state проекта добавлено поле:
    • projectCalc — JSON-блок с параметрами Проект → Настройка расчета:
      • simpleMonthly, simpleQuarterly, simpleHalfYear, simpleYearly
      • compoundMonthly, compoundQuarterly, compoundHalfYear, compoundYearly
  • Это изменение не требует новой SQL-миграции: хранится в общем JSON-снимке workspace state.

Проверка БД (2026-02-27, runtime)

  • Подключение к БД: SELECT 1OK
  • Проверка чтения ключевых таблиц (COUNT):
    • Project: 14
    • ProjectInfo: 7
    • ProjectSettings: 7
    • ProjectProduct: 16
    • ProjectCalendarNode: 15
    • ProjectWorkspaceState: 7
    • ProjectGeneralExpense: 32
    • ProjectGeneralExpenseScheduleItem: 10
    • ProjectPersonnelEmployee: 14

Обновление 2026-02-13

См. CHANGELOG.md. Добавлена настройка “Главная вкладка” (defaultWorkspaceViewId) — хранится в UserUiSettings.settings (JSON).

Обновление 2026-02-16

См. CHANGELOG.md. Раздел «Операционный план → План по персоналу» переведён на нормализованное хранение в БД. Добавлены таблицы:

  • ProjectPersonnelSettings
  • ProjectPersonnelSettingsPayoutItem
  • ProjectPersonnelEmployee
  • ProjectPersonnelEmployeePayoutItem

Важно:

  • Автосейв проекта пишет данные персонала в нормализованные таблицы.
  • Legacy JSON (ProjectWorkspaceState.state) продолжает сохраняться как fallback/бэкап для совместимости.

Обновление 2026-02-20

См. CHANGELOG.md. Раздел «Операционный план → Общие издержки» переведён на нормализованное хранение в БД.

Добавлены таблицы:

  • ProjectGeneralExpenseSettings
  • ProjectGeneralExpense
  • ProjectGeneralExpenseScheduleItem

Важно:

  • Чтение/сборка 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 planProjectPersonnelSettingsPayoutItem
Персонал: карточки сотрудников (группа, роль, ставка, оклад, дата найма, параметры выплат)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, image
  • company, position, phone
  • createdAt, updatedAt

Связи:

  • accounts[], sessions[] — данные NextAuth
  • workspaceMemberships[] — участие в рабочих пространствах
  • 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)
  • name
  • createdAt, 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

Связи:

  • workspace
  • workspaceState (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, scenario
  • startDate (Date), durationMonths (Int)

ProjectSettings — настройки проекта (валюта + визуальные настройки workspace)

Поля (основные):

  • projectId (PK, FK → Project.id)
  • projectCurrencyType, projectCurrencyName, projectCurrencyCode
  • themeId, bgColor, accentColor, paddingLeftPct, paddingRightPct, isHoverFxEnabled

ProjectProduct — продукты проекта

Поля (основные):

  • id (PK)
  • projectId (FK → Project.id)
  • sortIndex (для стабильного порядка в UI)
  • name, unit, status, note
  • currencyType, currencyName, currencyCode
  • price (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
  • projectId
  • projectId + date
  • productId

Где формируется:

  • 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)
  • sortIndex
  • dateType (day / eom)
  • dayOfMonth (Int)
  • mode (amount / percent)
  • value (Decimal(18,4))
  • createdAt, updatedAt

ProjectPersonnelEmployee — должности/позиции персонала проекта

Назначение: хранение карточек сотрудников (по факту — позиций с количеством ставок).

Поля:

  • projectId (часть PK, FK → Project.id)
  • id (часть PK, id сотрудника из UI)
  • sortIndex
  • groupName (группа: производство, управление и т.д.)
  • 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 правила)
  • sortIndex
  • dateType (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)
  • sortIndex
  • groupName, name, note
  • amount (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 элемента графика)
  • sortIndex
  • date (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)
  • sortIndex
  • title, longTitle
  • startDate, 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)
  • sortIndex
  • date (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;

Быстрая проверка “почему я не вижу календарь в БД?”

  1. Есть ли вообще узлы календаря в нормализованной таблице:
SELECT COUNT(*) AS nodes FROM "ProjectCalendarNode";
  1. Какие проекты без календарных узлов:
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;
  1. Есть ли 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, providerAccountId
  • access_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).

Поля:

  • identifier
  • token (уникальный)
  • expires

Служебные таблицы

Prisma

  • _prisma_migrations — журнал применённых миграций Prisma.

Django Admin (если вы запускали python django_admin/manage.py migrate)

Django создаст служебные таблицы, не связанные напрямую с Prisma-моделями приложения, например:

  • django_migrations
  • django_content_type
  • auth_user, auth_group, auth_permission, auth_group_permissions, auth_user_groups, auth_user_user_permissions
  • django_admin_log
  • django_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).