bablo / стройплощадка
← Все документы

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-postgrespostgres:16-alpine + pgvector5433Основная БД
bablo-appNext.js 15 (custom Dockerfile)3010Web-приложение
bablo-tg-botNode.js 22 + grammY3011Telegram-бот (long-polling)
bablo-doc-workerNode.js 22 (custom)3012Парсинг документов через Anthropic API
bablo-miniominio/minio:latest9001S3-совместимое хранилище файлов
bablo-transparencynginx-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. Подрядчики — накопительный учёт

При импорте акта подрядчика:

  1. AI парсит акт, извлекает объём и сумму
  2. Привязка к subcontract (если не указана автоматически — спросить у пользователя)
  3. Проверка: закрыто_до_этого + новый_объём > total_volume?
    • Если ДА для fixed_scope → алерт subcontract_overrun
    • Если для open_scope → алерт не создаётся
  4. После подтверждения пользователем — создаётся subcontract_progress
  5. На карточке подрядчика обновляется «закрыто / осталось»

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. Алерт «дубликат заказа»

При импорте КП или счёта на материал:

  1. Для каждой document_line определяется item_id через матчинг
  2. Поиск других документов того же project_id с тем же item_id, статусом parsed или confirmed, за последние 90 дней
  3. Если найдены — создаётся алерт duplicate_order со ссылками на оба документа
  4. severity = warning (если суммарный объём не превышает эталон калькулятора — может быть нормальный добор)
  5. severity = critical (если суммарный объём существенно превышает эталон)

4. API endpoints

Все endpoints — Next.js API Routes (/api/...), кроме упомянутых исключений. Аутентификация — через session cookie (Lucia).

4.1. Auth

MethodPathОписание
POST/api/auth/loginEmail + password → session
POST/api/auth/magic-linkЗапрос magic link
GET/api/auth/magic-link/verifyПодтверждение magic link → session
POST/api/auth/logoutВыход

4.2. Объекты

MethodPathОписание
GET/api/projectsСписок объектов организации
POST/api/projectsСоздать новый объект
GET/api/projects/:idКарточка объекта (включая маржу, документы, алерты)
PATCH/api/projects/:idОбновление
POST/api/projects/:id/archiveАрхивация

4.3. Документы

MethodPathОписание
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. Подрядчики

MethodPathОписание
GET/api/projects/:id/subcontractsСписок договоров с подрядчиками
POST/api/projects/:id/subcontractsНовый договор подрядчика
GET/api/subcontracts/:idКарточка договора с историей актов
POST/api/subcontracts/:id/actsДобавить акт закрытия объёма

4.5. Финансы

MethodPathОписание
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. Алерты

MethodPathОписание
GET/api/projects/:id/alertsСписок алертов с фильтрами
PATCH/api/alerts/:idИзменение статуса алерта (resolved/ignored)
GET/api/dashboard/alertsВсе critical-алерты по всем объектам

4.7. Калькуляторы

MethodPathОписание
GET/api/calculatorsСписок доступных калькуляторов
POST/api/projects/:id/calculationsЗапустить расчёт
GET/api/calculations/:idРезультат расчёта

4.8. Webhook Telegram

MethodPathОписание
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 метрики в одном ряду:

  1. Маржа (большая цифра + %)
  2. Бюджет (план / факт / остаток)
  3. Поступления (получено / план)
  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. Загрузка документа

Пользователь пересылает файл / фото / текст.

Бот:

  1. Сохраняет файл в MinIO, создаёт запись document
  2. Если у пользователя несколько объектов и контекст неоднозначен → inline-кнопки «Серафимовича», «Объект 2»…
  3. После выбора — статус «Разбираю…» (с обновлением сообщения)
  4. По завершении парсинга — 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, warning yellow-500, danger red-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.

  1. Эмбеддинги: Voyage AI vs OpenAI vs локальные? Лучший по качеству для русского текста — Voyage voyage-3. По цене — окей. Рекомендация: Voyage. Открыто до подтверждения.

  2. Очереди для doc-worker: BullMQ (Redis) или встроенная очередь PostgreSQL (pgmq)? В Phase 1 объём небольшой (10-20 документов/день), можно без Redis. Рекомендация: начать с pgmq, при росте — мигрировать на BullMQ.

  3. State management в Next.js: только server components + revalidate, или Zustand для клиентской части? Рекомендация: server-first, Zustand точечно для интерактивных форм.

  4. Backup destination: куда дублировать бэкапы вне rigabase? Открыто. Варианты: Yandex Object Storage, личный домашний NAS, отдельный VPS.

  5. Доменные имена для bablo: какие использовать? Открыто. Зависит от того, какие домены уже зарегистрированы Романом.

  6. Sentry: использовать SaaS-версию или self-hosted? SaaS быстрее. Self-hosted — российская юрисдикция. Рекомендация: SaaS Sentry в Phase 1, миграция на self-hosted в Phase 2 если будет проблема.