03 — PRD (Product Requirements Document)
Техническое задание на Phase 1. Документ для разработки. Авторитетный источник для всех технических решений; противоречия с другими документами решаются в пользу этого PRD.
1. Системная архитектура
1.1. Высокоуровневая схема
┌─────────────────────────────────────┐
│ Nginx Proxy Manager (npm-app-1) │
│ :80 / :443 (HTTPS, Let's Encrypt) │
└────────────────┬────────────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
│ bablo-app │ │bablo-transp- │ │ Telegram cloud │
│ Next.js 15 │ │arency (Astro)│ │ (Bot API) │
│ :3010 │ │ :3013 │ └─────────────────┘
└──────┬───────┘ └──────────────┘ ▲
│ │
│ long-polling
│ │
│ ┌────────┴─────────┐
│ │ bablo-tg-bot │
│ │ grammY :3011 │
│ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ Internal Docker Network │
└──────────┬────────────────────┬──────────────────────┬──────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ bablo- │ │ bablo-minio │ │ bablo-postgres │
│ doc-worker │ │ S3-storage │ │ PG 16 + pgvector │
│ :3012 │ │ :9001 │ │ :5433 │
└──────┬───────┘ └──────────────┘ └──────────────────┘
│
▼
┌──────────────────────┐
│ Anthropic API │
│ (Opus/Sonnet/Haiku) │
│ External │
└──────────────────────┘
1.2. Контейнеры
| Контейнер | Образ/Технология | Внутренний порт | Назначение |
|---|---|---|---|
bablo-postgres | postgres:16-alpine + pgvector | 5433 | Основная БД |
bablo-app | Next.js 15 (custom Dockerfile) | 3010 | Web-приложение |
bablo-tg-bot | Node.js 22 + grammY | 3011 | Telegram-бот (long-polling) |
bablo-doc-worker | Node.js 22 (custom) | 3012 | Парсинг документов через Anthropic API |
bablo-minio | minio/minio:latest | 9001 | S3-совместимое хранилище файлов |
bablo-transparency | nginx-alpine + статика | 3013 | Стройплощадка (документация для клиента) |
1.3. Внешние зависимости
- Anthropic API — для AI-парсинга и эмбеддингов (если используем Anthropic embeddings)
- Voyage AI или OpenAI Embeddings — для эмбеддингов позиций (альтернатива)
- Telegram Bot API — для бота (long-polling)
- Let’s Encrypt — через NPM для HTTPS
2. Модель данных
Примечание: SQL-блоки ниже — иллюстрация целевой схемы, а не готовый для последовательного выполнения скрипт. Порядок DDL не соблюдён намеренно (например, в §2.4
payment_event.related_subcontract_idссылается наsubcontract, который определён ниже в §2.5). Реальная схема создаётся и применяется через Drizzle ORM / Drizzle Kit, который сам разрешает порядок зависимостей и миграции. Не запускать эти блоки как одинpsql-скрипт.
2.1. Базовые сущности
-- Организация (в Phase 1 — одна, под Руслана; в Phase 2 — multi-tenant)
CREATE TABLE organization (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
inn TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Пользователь
CREATE TABLE user_account (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
email TEXT UNIQUE NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
telegram_id BIGINT UNIQUE,
telegram_username TEXT,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Сессия (Lucia)
CREATE TABLE user_session (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL
);
-- Объект (стройка)
CREATE TABLE project (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
name TEXT NOT NULL,
address TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'finished', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb
);
-- Контрагент
CREATE TABLE counterparty (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
name TEXT NOT NULL,
inn TEXT,
kpp TEXT,
roles TEXT[] NOT NULL DEFAULT '{}', -- supplier, customer, subcontractor
bank_details JSONB,
contact_info JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, inn)
);
2.2. Сметы
CREATE TABLE estimate (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id) ON DELETE CASCADE,
code TEXT, -- "02-01-01"
name TEXT NOT NULL, -- "Фасады"
methodology TEXT, -- "ТСН-2001", "ФЕР", "ГЭСН"
active_version_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE estimate_version (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
estimate_id UUID NOT NULL REFERENCES estimate(id) ON DELETE CASCADE,
version_number INT NOT NULL,
source_document_id UUID,
total_base NUMERIC(18,2),
total_current NUMERIC(18,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (estimate_id, version_number)
);
CREATE TABLE estimate_section (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
estimate_version_id UUID NOT NULL REFERENCES estimate_version(id) ON DELETE CASCADE,
parent_id UUID REFERENCES estimate_section(id),
name TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE estimate_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
estimate_version_id UUID NOT NULL REFERENCES estimate_version(id) ON DELETE CASCADE,
section_id UUID REFERENCES estimate_section(id),
position_number TEXT,
rate_code TEXT,
name TEXT NOT NULL,
unit TEXT NOT NULL,
quantity NUMERIC(18,4) NOT NULL,
unit_price_base NUMERIC(18,4),
total_base NUMERIC(18,2),
total_current NUMERIC(18,2),
coefficients JSONB,
composition JSONB
);
2.3. Каталог позиций и документы
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE item_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
canonical_name TEXT NOT NULL,
category TEXT,
unit TEXT NOT NULL,
specs JSONB,
aliases TEXT[] NOT NULL DEFAULT '{}',
embedding VECTOR(1024), -- для Voyage voyage-3
requires_review BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_item_catalog_embedding ON item_catalog
USING ivfflat (embedding vector_cosine_ops);
CREATE INDEX idx_item_catalog_aliases ON item_catalog USING GIN (aliases);
CREATE TABLE document (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES project(id),
counterparty_id UUID REFERENCES counterparty(id),
doc_type TEXT NOT NULL CHECK (doc_type IN (
'estimate', 'commercial_offer', 'invoice', 'contract',
'act', 'whatsapp_message', 'telegram_message', 'other'
)),
doc_number TEXT,
doc_date DATE,
payment_model TEXT CHECK (payment_model IN ('purchase', 'rent', 'service', 'mixed') OR payment_model IS NULL),
rent_period_days INT,
total_amount NUMERIC(18,2),
vat_rate NUMERIC(5,2),
currency TEXT NOT NULL DEFAULT 'RUB',
source_url TEXT, -- путь в MinIO
raw_text TEXT,
parsed_content JSONB,
parsing_status TEXT NOT NULL DEFAULT 'pending' CHECK (parsing_status IN ('pending', 'parsing', 'parsed', 'needs_review', 'failed')),
parsing_model TEXT,
parsing_confidence NUMERIC(3,2),
input_channel TEXT NOT NULL CHECK (input_channel IN ('upload', 'camera', 'text_paste', 'telegram_bot', 'email')),
uploaded_by UUID REFERENCES user_account(id),
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE document_line (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE,
line_number INT,
raw_name TEXT NOT NULL,
item_id UUID REFERENCES item_catalog(id),
match_confidence NUMERIC(3,2),
match_status TEXT NOT NULL DEFAULT 'auto' CHECK (match_status IN ('auto', 'manual', 'unmatched', 'review')),
unit TEXT,
quantity NUMERIC(18,4) NOT NULL,
unit_price NUMERIC(18,4),
price_period TEXT CHECK (price_period IN ('one_time', 'per_day', 'per_month') OR price_period IS NULL),
total_amount NUMERIC(18,2),
cost_component_type TEXT CHECK (cost_component_type IN ('good', 'delivery', 'install', 'deposit', 'discount', 'vat', 'other')),
weight NUMERIC(18,3),
metadata JSONB
);
CREATE TABLE estimate_to_item_link (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
estimate_line_id UUID NOT NULL REFERENCES estimate_line(id) ON DELETE CASCADE,
item_id UUID NOT NULL REFERENCES item_catalog(id),
expected_quantity NUMERIC(18,4),
notes TEXT,
UNIQUE (estimate_line_id, item_id)
);
2.4. Финансовая модель
CREATE TABLE contract (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
counterparty_id UUID NOT NULL REFERENCES counterparty(id),
contract_type TEXT NOT NULL CHECK (contract_type IN ('customer', 'subcontractor')),
number TEXT,
signed_date DATE,
total_amount NUMERIC(18,2) NOT NULL,
payment_schema JSONB NOT NULL DEFAULT '{"type": "advance_plus_remainder"}'::jsonb,
-- payment_schema варианты:
-- {"type": "advance_plus_remainder", "advance_pct": 80}
-- {"type": "no_advance"}
-- {"type": "staged", "stages": [{"trigger": "kc2", "pct": 30}, ...]}
document_id UUID REFERENCES document(id),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE revenue_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contract(id),
event_type TEXT NOT NULL CHECK (event_type IN ('advance', 'staged_payment', 'final_payment')),
planned_amount NUMERIC(18,2),
planned_date DATE,
actual_amount NUMERIC(18,2),
actual_date DATE,
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'received', 'overdue', 'cancelled')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE extra_cost_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organization(id),
name TEXT NOT NULL,
is_preset BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE extra_cost (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
category_id UUID NOT NULL REFERENCES extra_cost_category(id),
amount NUMERIC(18,2) NOT NULL,
date DATE NOT NULL,
description TEXT NOT NULL,
counterparty_id UUID REFERENCES counterparty(id),
document_id UUID REFERENCES document(id),
created_by UUID NOT NULL REFERENCES user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE payment_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
counterparty_id UUID NOT NULL REFERENCES counterparty(id),
amount NUMERIC(18,2) NOT NULL,
date DATE NOT NULL,
payment_type TEXT NOT NULL CHECK (payment_type IN ('supplier_payment', 'subcontractor_payment', 'other')),
related_document_id UUID REFERENCES document(id),
related_subcontract_id UUID REFERENCES subcontract(id),
description TEXT,
created_by UUID NOT NULL REFERENCES user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE margin_snapshot (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
snapshot_date DATE NOT NULL,
revenue_actual NUMERIC(18,2) NOT NULL,
estimate_costs NUMERIC(18,2) NOT NULL,
extra_costs NUMERIC(18,2) NOT NULL,
margin_amount NUMERIC(18,2) NOT NULL,
margin_pct NUMERIC(5,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, snapshot_date)
);
2.5. Подрядчики (Subcontract Tracker)
CREATE TABLE subcontract (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contract(id),
work_description TEXT NOT NULL,
scope_type TEXT NOT NULL CHECK (scope_type IN ('fixed_scope', 'open_scope')),
total_volume NUMERIC(18,4), -- NULL для open_scope
unit TEXT,
unit_price NUMERIC(18,4) NOT NULL,
total_amount NUMERIC(18,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE subcontract_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subcontract_id UUID NOT NULL REFERENCES subcontract(id),
act_document_id UUID REFERENCES document(id),
closed_volume NUMERIC(18,4) NOT NULL,
closed_amount NUMERIC(18,2) NOT NULL,
act_date DATE NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
2.6. Калькуляторы
CREATE TABLE calculator_template (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, -- "Хомутовые леса"
category TEXT NOT NULL, -- "scaffolding"
input_schema JSONB NOT NULL, -- { "area_m2": "number", "height_m": "number", ... }
output_items JSONB NOT NULL, -- список позиций каталога с коэффициентами
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE calculation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
template_id UUID NOT NULL REFERENCES calculator_template(id),
inputs JSONB NOT NULL,
outputs JSONB NOT NULL, -- эталонные количества по позициям
intermediate JSONB, -- промежуточные расчёты для прозрачности
notes TEXT,
created_by UUID NOT NULL REFERENCES user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
2.7. Алерты
CREATE TABLE alert (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES project(id),
severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'critical')),
alert_type TEXT NOT NULL,
-- alert_type значения:
-- duplicate_order, qty_below_calculation, qty_above_calculation,
-- subcontract_overrun, price_outlier, margin_drop, etc.
title TEXT NOT NULL,
description TEXT NOT NULL,
potential_loss NUMERIC(18,2), -- расчётная сумма потенциальной потери
related_entities JSONB NOT NULL, -- ссылки на документы/позиции/договоры
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved', 'ignored')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by UUID REFERENCES user_account(id),
resolution_note TEXT,
actual_saving NUMERIC(18,2) -- фактическая сумма экономии (если resolved)
);
CREATE INDEX idx_alert_project_status ON alert (project_id, status);
2.8. Индексы (общие)
CREATE INDEX idx_document_project ON document (project_id);
CREATE INDEX idx_document_counterparty ON document (counterparty_id);
CREATE INDEX idx_document_received_at ON document (received_at DESC);
CREATE INDEX idx_document_line_item ON document_line (item_id);
CREATE INDEX idx_estimate_line_section ON estimate_line (section_id);
CREATE INDEX idx_subcontract_progress_subcontract ON subcontract_progress (subcontract_id);
CREATE INDEX idx_revenue_event_contract ON revenue_event (contract_id);
CREATE INDEX idx_extra_cost_project_date ON extra_cost (project_id, date DESC);
CREATE INDEX idx_margin_snapshot_project_date ON margin_snapshot (project_id, snapshot_date DESC);
3. Бизнес-логика
3.1. Парсинг документов (pipeline)
1. Документ попадает в систему (через web upload, Telegram-бот, или text paste)
2. Файл сохраняется в MinIO, создаётся запись `document` со статусом `pending`
3. Воркер `bablo-doc-worker` берёт документ из очереди
4. По типу файла выбирается стратегия:
- PDF text-based → pdf-parse → передача в Claude
- PDF scan-based → Claude Vision (или OCR + текст)
- XLSX → exceljs/xlsx → структурированный JSON
- JPG/PNG → Claude Vision
- Текст → Claude Sonnet/Haiku
5. Выбор модели:
- Сложные PDF (таблицы, мелкий шрифт, плохое качество) → Claude Opus 4.7
- Простой текст из мессенджера, типовые формы → Claude Haiku 4.5
- Всё остальное → Claude Sonnet 4.6
6. Результат: JSON с распознанным контентом + confidence score
7. Создаются `document_line` записи
8. Для каждой `document_line` запускается матчинг с `item_catalog`
9. Статус документа меняется на `parsed` (или `needs_review` если confidence < 0.7)
10. UI пользователя получает уведомление (web — через polling или SSE, Telegram — inline reply от бота)
Стоимость каждого вызова Anthropic API логируется в expenses.yaml через автоматизацию (об этом — отдельный раздел).
3.2. Матчинг позиций (item matching)
Для каждой document_line:
1. Генерируется эмбеддинг от raw_name через Voyage AI (voyage-3, 1024 dim)
2. pgvector ищет top-5 ближайших по косинусной близости в item_catalog
3. Если top-1 имеет similarity >= 0.92:
→ автоматический матчинг, match_status = 'auto', confidence = similarity
4. Если top-1 similarity 0.75-0.92:
→ LLM-проверка: «Эти две позиции — одно и то же?»
→ если да → match_status = 'auto', confidence = LLM-уверенность
→ если нет → переходим к шагу 5
5. Если top-1 similarity < 0.75 или LLM сказал «не то»:
→ match_status = 'unmatched'
→ создаётся новая запись в item_catalog с requires_review = TRUE
→ document_line.item_id = id новой записи
→ алерт пользователю «новая позиция, проверь категоризацию»
6. Пользователь может вручную пере-привязать через UI:
→ match_status = 'manual'
→ raw_name добавляется в aliases оригинальной item_catalog записи
→ автообучение через накопление aliases
3.3. Расчёт маржи
Триггеры пересчёта (любой из них):
- Изменился
revenue_event(фактическая дата/сумма) - Изменился
payment_event - Изменился
extra_cost - Изменилась активная версия сметы
Формула:
margin_amount = SUM(revenue_event.actual_amount WHERE status='received')
- SUM(payment_event.amount WHERE date <= today)
- SUM(extra_cost.amount WHERE date <= today)
margin_pct = margin_amount / contract.total_amount * 100
Пересчёт создаёт новую запись margin_snapshot с текущей датой. Старые снапшоты не удаляются — это история.
Цветовой индикатор маржи на UI:
- 🟢 Зелёный: margin_pct >= 10%
- 🟡 Жёлтый: 5% <= margin_pct < 10%
- 🔴 Красный: margin_pct < 5%
3.4. Подрядчики — накопительный учёт
При импорте акта подрядчика:
- AI парсит акт, извлекает объём и сумму
- Привязка к
subcontract(если не указана автоматически — спросить у пользователя) - Проверка:
закрыто_до_этого + новый_объём > total_volume?- Если ДА для
fixed_scope→ алертsubcontract_overrun - Если для
open_scope→ алерт не создаётся
- Если ДА для
- После подтверждения пользователем — создаётся
subcontract_progress - На карточке подрядчика обновляется «закрыто / осталось»
3.5. Калькулятор хомутовых лесов
Входные параметры:
area_m2(обязательный) — площадь фасадовheight_m(обязательный) — средняя высотаperimeter_m(опциональный) — если не указан, рассчитывается какarea_m2 / height_m
Промежуточные расчёты:
tiers = ceil(height_m / 2.0) -- ярусы по 2м
sections = ceil(perimeter_m / 3.0) -- секции по 3м (стандарт ЛСПХ)
posts_4m_count = sections * (tiers / 2) -- стойки 4м (одна на 2 яруса)
posts_2m_count = sections * (tiers % 2) -- стойки 2м (доборные)
ties_5_3m_count = (tiers + 1) * sections -- горизонтальные связи
crossbeams_count = sections * tiers -- поперечины
clamps_count = (posts_4m_count + posts_2m_count) * tiers * 2 -- хомуты н/п
decking_count = sections * tiers * 1.05 -- настилы (с 5% запасом)
ladders_count = ceil(perimeter_m / 50) -- лестницы
Конкретные коэффициенты вынесены в lib/calculators/scaffolding-coefficients.ts для возможности корректировки без релиза.
Алерт по сравнению с КП:
- Для каждой позиции рассчитанной комплектации сравнивается с тем, что в КП поставщика
- Отклонение >5% →
qty_below_calculationилиqty_above_calculation - Стоимость потенциальной потери =
недостающий_объём * средняя_цена_за_единицу
3.6. Алерт «дубликат заказа»
При импорте КП или счёта на материал:
- Для каждой
document_lineопределяетсяitem_idчерез матчинг - Поиск других документов того же
project_idс тем жеitem_id, статусомparsedилиconfirmed, за последние 90 дней - Если найдены — создаётся алерт
duplicate_orderсо ссылками на оба документа - severity = warning (если суммарный объём не превышает эталон калькулятора — может быть нормальный добор)
- severity = critical (если суммарный объём существенно превышает эталон)
4. API endpoints
Все endpoints — Next.js API Routes (/api/...), кроме упомянутых исключений. Аутентификация — через session cookie (Lucia).
4.1. Auth
| Method | Path | Описание |
|---|---|---|
| POST | /api/auth/login | Email + password → session |
| POST | /api/auth/magic-link | Запрос magic link |
| GET | /api/auth/magic-link/verify | Подтверждение magic link → session |
| POST | /api/auth/logout | Выход |
4.2. Объекты
| Method | Path | Описание |
|---|---|---|
| GET | /api/projects | Список объектов организации |
| POST | /api/projects | Создать новый объект |
| GET | /api/projects/:id | Карточка объекта (включая маржу, документы, алерты) |
| PATCH | /api/projects/:id | Обновление |
| POST | /api/projects/:id/archive | Архивация |
4.3. Документы
| Method | Path | Описание |
|---|---|---|
| GET | /api/projects/:id/documents | Список документов объекта (с пагинацией) |
| POST | /api/projects/:id/documents | Загрузка документа (multipart/form-data) |
| GET | /api/documents/:id | Документ с распарсенными строками |
| PATCH | /api/documents/:id/lines/:lineId | Ручная правка строки |
| POST | /api/documents/:id/reparse | Запрос на повторный парсинг (другая модель) |
4.4. Подрядчики
| Method | Path | Описание |
|---|---|---|
| GET | /api/projects/:id/subcontracts | Список договоров с подрядчиками |
| POST | /api/projects/:id/subcontracts | Новый договор подрядчика |
| GET | /api/subcontracts/:id | Карточка договора с историей актов |
| POST | /api/subcontracts/:id/acts | Добавить акт закрытия объёма |
4.5. Финансы
| Method | Path | Описание |
|---|---|---|
| GET | /api/projects/:id/margin | Текущая маржа с разбивкой |
| GET | /api/projects/:id/margin/history | История margin_snapshot |
| POST | /api/projects/:id/extra-costs | Добавить внесметный расход |
| GET | /api/projects/:id/revenue | Все revenue_events объекта |
| POST | /api/projects/:id/revenue | Зафиксировать поступление от заказчика |
| GET | /api/projects/:id/payments | Все payment_events объекта |
4.6. Алерты
| Method | Path | Описание |
|---|---|---|
| GET | /api/projects/:id/alerts | Список алертов с фильтрами |
| PATCH | /api/alerts/:id | Изменение статуса алерта (resolved/ignored) |
| GET | /api/dashboard/alerts | Все critical-алерты по всем объектам |
4.7. Калькуляторы
| Method | Path | Описание |
|---|---|---|
| GET | /api/calculators | Список доступных калькуляторов |
| POST | /api/projects/:id/calculations | Запустить расчёт |
| GET | /api/calculations/:id | Результат расчёта |
4.8. Webhook Telegram
| Method | Path | Описание |
|---|---|---|
| POST | /api/telegram/webhook | Зарезервировано, в Phase 1 не используется (long-polling) |
5. UI — основные экраны
5.1. Структура навигации
/ Главный экран (портфельный дашборд)
/projects/[id] Главный экран объекта (маржа, последнее)
/projects/[id]/estimate Смета объекта
/projects/[id]/documents Документы объекта
/projects/[id]/documents/[docId] Просмотр конкретного документа
/projects/[id]/subcontracts Подрядчики
/projects/[id]/calculations История расчётов калькуляторов
/projects/[id]/alerts Алерты по объекту
/projects/[id]/finance Финансы (договор, поступления, расходы)
/login Логин
/settings Настройки пользователя
5.2. Главный экран — портфельный дашборд
Содержание:
- Заголовок: «Объекты»
- Карточки объектов (по одной на каждый активный):
- Название объекта
- Маржа большой цифрой (text-3xl, цветной индикатор)
- Под маржей: «получено X из Y ₽»
- Risk Badge: «В норме» / «Внимание» / «Риск»
- Hover: подсветка границы
- Фильтр: «Активные» / «Завершённые» / «Все»
- Сортировка: «С риском сверху» (default) / «По дате» / «По марже»
- Кнопка «+ Новый объект»
5.3. Главный экран объекта
Вкладки (tab navigation):
- Обзор (default)
- Смета
- Документы
- Подрядчики
- Финансы
- Алерты
- Настройки
На вкладке «Обзор» — 4 метрики в одном ряду:
- Маржа (большая цифра + %)
- Бюджет (план / факт / остаток)
- Поступления (получено / план)
- Открытых алертов (количество, кликабельно)
Ниже:
- «Последние документы» (5 строк)
- «Открытые алерты» (5 топ по severity)
- «Маржа во времени» (sparkline за 30 дней)
6. Telegram-бот — UX
6.1. Привязка пользователя
Пользователь получает приглашение → ссылка вида https://t.me/bablo_bot?start=<invite_token>.
Примечание:
bablo_botи имя «bablo» — рабочие плейсхолдеры. Финальный username бота и название зависят от решения по имени проекта (OPEN_QUESTIONS.md, вопрос 1). До решения — рабочее название.
При первом запуске:
- Бот определяет токен → находит соответствующего пользователя в БД
- Сохраняет
telegram_idиtelegram_usernameв user_account - Отвечает: «Привет, [имя]! Я бот bablo. Перешлите мне любой счёт, КП или акт — я разберу.»
6.2. Загрузка документа
Пользователь пересылает файл / фото / текст.
Бот:
- Сохраняет файл в MinIO, создаёт запись document
- Если у пользователя несколько объектов и контекст неоднозначен → inline-кнопки «Серафимовича», «Объект 2»…
- После выбора — статус «Разбираю…» (с обновлением сообщения)
- По завершении парсинга — inline-ответ:
✅ Разобран счёт от «Опалубка-Домстрой» №8747 на 12 541 430 ₽ 10 позиций по лесам. ⚠ Стойка 4м: 1620 шт по 1415 ₽, у СистемаТрейд 1700 шт по 1315 ₽ Разница в стоимости ~162 тыс ₽ [Открыть в браузере]
6.3. Команды
/start— привязка / приветствие/projects— список объектов/help— справка
7. Дизайн-система
Цветовая палитра, типографика, токены — см. docs/plaid/05-DESIGN.md (единый источник правды по визуальному языку).
Ключевые принципы:
- Тёмная тема по умолчанию (
bg-zinc-950,text-zinc-50) - Семантические цвета: success
green-500, warningyellow-500, dangerred-500 - Главные числа — моноширинный JetBrains Mono
- Интерфейсный текст — Inter
- Радиус скругления 4px
- Минимум анимаций, никаких эмодзи в продакшен-UI (кроме статусных 🟢🟡🔴)
8. Нефункциональные требования
8.1. Производительность
- Время загрузки главного экрана: < 1,5 сек
- Время от загрузки документа до разбора: < 60 сек (95-й перцентиль)
- API endpoints: < 200ms response time (без AI-обработки)
8.2. Качество парсинга (целевое)
- КП: precision >= 85%, recall >= 85%
- Счета: precision >= 90%, recall >= 90%
- Акты в свободной форме: precision >= 75%, recall >= 75%
- Текст из мессенджера: precision >= 80%, recall >= 80%
8.3. Безопасность
- Все API endpoints требуют аутентификации (кроме
/api/auth/*) - Row Level Security в PostgreSQL: пользователь видит только данные своей organization
- Файлы в MinIO — приватный bucket, доступ только через signed URL
- Сессии — http-only, secure, sameSite=lax
- Rate limiting на /api/auth/login: 5 попыток / 15 минут / IP
- CORS: только same-origin (отдельных клиентов нет)
8.4. Бэкапы
- PostgreSQL: ежедневный dump в
/opt/bablo/volumes/postgres-backups/(хранить 30 дней) - MinIO: ежедневный sync в отдельный backup-bucket (хранить 30 дней)
- Скрипт
infra/scripts/backup.shзапускается через cron в 03:00 МСК - Bбекапы дублируются (rsync) на резервное хранилище (TODO: уточнить куда — на отдельный диск или в Yandex Object Storage)
8.5. Мониторинг
В Phase 1 — минимум:
- Sentry для отлова исключений (frontend + backend)
- Логи контейнеров через Docker logs (без специального стека)
- Healthcheck для каждого контейнера в docker-compose
9. Открытые технические вопросы
Эти вопросы должны быть решены до или в начале разработки. Каждый — кандидат на ADR.
-
Эмбеддинги: Voyage AI vs OpenAI vs локальные? Лучший по качеству для русского текста — Voyage
voyage-3. По цене — окей. Рекомендация: Voyage. Открыто до подтверждения. -
Очереди для doc-worker: BullMQ (Redis) или встроенная очередь PostgreSQL (pgmq)? В Phase 1 объём небольшой (10-20 документов/день), можно без Redis. Рекомендация: начать с pgmq, при росте — мигрировать на BullMQ.
-
State management в Next.js: только server components + revalidate, или Zustand для клиентской части? Рекомендация: server-first, Zustand точечно для интерактивных форм.
-
Backup destination: куда дублировать бэкапы вне rigabase? Открыто. Варианты: Yandex Object Storage, личный домашний NAS, отдельный VPS.
-
Доменные имена для bablo: какие использовать? Открыто. Зависит от того, какие домены уже зарегистрированы Романом.
-
Sentry: использовать SaaS-версию или self-hosted? SaaS быстрее. Self-hosted — российская юрисдикция. Рекомендация: SaaS Sentry в Phase 1, миграция на self-hosted в Phase 2 если будет проблема.