Files
bit-flight-deck/docs/superpowers/plans/2026-05-13-mvp1-workload.md
T
2026-05-13 19:47:53 +05:00

1387 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MVP-1 «Загрузка сотрудников» Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Развернуть рабочий read-only аналитический слой над BIT.RA + EVA + Bitrix24 с дашбордом «Загрузка сотрудников» (4 слоя: факт / текущая / плановая-как-«осталось» / прогноз) в Metabase для команды ~20 сотрудников.
**Architecture:** PostgreSQL (отдельная БД `bit_flight_deck` в существующем `pipeline_postgres`), N8N для оркестрации (cron + Bitrix webhooks через CF Tunnel), HTTP-сервисы 1С в BIT.RA, JSON-RPC pull из EVA, REST + webhooks из Битрикса. SQL views/procedures для трансформаций raw → stg → core → mart. Metabase для дашбордов, NocoDB для управления identity_map.
**Tech Stack:** PostgreSQL 15, N8N (existing), Docker Compose, Metabase, NocoDB, Cloudflare Tunnel (existing), 1С:Предприятие 8.3, JSON-RPC, REST.
**Spec:** [docs/superpowers/specs/2026-05-13-mvp1-workload-design.md](../specs/2026-05-13-mvp1-workload-design.md)
---
## File Structure
```
~/projects/bit-flight-deck/ ← локальная папка проекта (WSL)
├── docker-compose.yml Metabase + NocoDB, подключение к pipeline_net
├── .env секреты (не в git)
├── .env.example шаблон
├── README.md (уже создан)
├── .gitignore (уже создан)
├── infra/
│ ├── init-bit-flight-deck-db.sql создание БД + пользователя + схем
│ ├── cloudflared-routes.md документация по поддоменам tunnel
│ └── pg_backup.sh ручной cron-скрипт бэкапа
├── sql/
│ ├── migrations/
│ │ ├── 001_schemas.sql CREATE SCHEMA для raw/stg/core/mart
│ │ ├── 002_core_employee.sql core.employee + core.department + history
│ │ ├── 003_core_project.sql core.project + core.stage
│ │ ├── 004_core_work_log.sql core.work_log + core.work_type
│ │ ├── 005_core_task.sql core.task
│ │ ├── 006_core_deal.sql core.deal + core.deal_team_member
│ │ ├── 007_core_identity_map.sql core.identity_map
│ │ ├── 008_raw_schemas.sql raw_bitra.* / raw_eva.* / raw_bitrix.* таблицы
│ │ └── 009_stg_schemas.sql stg_* таблицы (плоские, нормализованные из raw)
│ ├── views/
│ │ ├── stg_bitra_employee.sql VIEW из raw_bitra.employee → stg_bitra.employee
│ │ ├── stg_bitra_works.sql ... (по одной view на сущность каждого источника)
│ │ ├── stg_eva_person.sql
│ │ ├── stg_eva_task.sql
│ │ ├── stg_bitrix_deal.sql
│ │ ├── stg_bitrix_user.sql
│ │ ├── mart_workload_actual.sql VIEW для дашборда — факт
│ │ ├── mart_workload_current.sql текущая
│ │ ├── mart_workload_planned.sql плановая (как «осталось»)
│ │ ├── mart_workload_forecast.sql прогноз
│ │ ├── mart_workload_summary.sql composite
│ │ └── mart_tasks_orphan.sql метрика качества
│ ├── procedures/
│ │ ├── core_merge_employee.sql SP «merge stg_*.employee → core.employee + identity_map»
│ │ ├── core_merge_project.sql
│ │ ├── core_merge_work_log.sql
│ │ ├── core_merge_task.sql
│ │ ├── core_merge_deal.sql
│ │ └── mart_refresh_all.sql пересоздание materialized views (если будут)
│ └── seed/
│ ├── work_types.sql 14 значений Enum.ВидыРабот с категориями (commercial/internal/free/ignored)
│ ├── sd_projects_whitelist.sql 3 ID SD-проектов EVA
│ └── bitrix_stages_forecast.sql список стадий CAT=16 для прогноза
├── n8n/
│ └── workflows/ JSON-экспорты workflows
│ ├── 01-pull-bitra-employees.json
│ ├── 02-pull-bitra-works.json
│ ├── 03-pull-bitra-projects-dict.json
│ ├── 04-pull-eva-incremental.json
│ ├── 05-pull-eva-nightly-full.json
│ ├── 06-bitrix-webhook-handler.json
│ ├── 07-pull-bitrix-nightly-reconcile.json
│ ├── 08-trigger-transforms.json
│ └── 99-health-check.json
└── docs/superpowers/
├── specs/... (уже есть)
└── plans/2026-05-13-mvp1-workload.md этот файл
В BIT.RA (1С-разработка, отдельная история):
└── Расширение или общий модуль IntegrationAPI с HTTP-сервисами:
GET /api/employees
GET /api/works
GET /api/projects
GET /api/dictionaries
GET /api/work_types
GET /api/dept_history
```
---
## Phase 0: Project Bootstrap
### Task 0.1: Создать локальный проект в WSL
**Files:**
- Create: `~/projects/bit-flight-deck/` (если ещё нет)
- Clone: `https://gitea.bigmadnekenny.ru/admin/bit-flight-deck.git`
- [ ] **Step 1:** В WSL перейти в `~/projects/`:
```bash
mkdir -p ~/projects && cd ~/projects
```
- [ ] **Step 2:** Клонировать репо:
```bash
git clone https://gitea.bigmadnekenny.ru/admin/bit-flight-deck.git
cd bit-flight-deck
```
- [ ] **Step 3:** Проверить что docs/, README.md, .gitignore на месте:
```bash
ls -la
cat docs/superpowers/specs/2026-05-13-mvp1-workload-design.md | head
```
Ожидание: видим спеку и наброски MVP-2/3/4.
### Task 0.2: Создать БД bit_flight_deck и пользователя
**Files:**
- Create: `infra/init-bit-flight-deck-db.sql`
- [ ] **Step 1:** Создать файл `infra/init-bit-flight-deck-db.sql`:
```sql
-- Запускается ОДИН раз на pipeline_postgres от имени суперпользователя.
CREATE DATABASE bit_flight_deck;
CREATE USER bit_flight_deck_user WITH PASSWORD '<СГЕНЕРИРОВАТЬ_СИЛЬНЫЙ_PASSWORD>';
GRANT ALL PRIVILEGES ON DATABASE bit_flight_deck TO bit_flight_deck_user;
ALTER DATABASE bit_flight_deck OWNER TO bit_flight_deck_user;
```
- [ ] **Step 2:** Сгенерировать пароль:
```bash
openssl rand -hex 16
```
Подставить в .sql вместо `<СГЕНЕРИРОВАТЬ_СИЛЬНЫЙ_PASSWORD>`.
- [ ] **Step 3:** Записать пароль в `.env` (создать файл, в git не коммитим):
```
PG_USER=bit_flight_deck_user
PG_PASSWORD=<сгенерированный пароль>
PG_DB=bit_flight_deck
PG_HOST=pipeline_postgres
PG_PORT=5432
```
- [ ] **Step 4:** Применить SQL к pipeline_postgres:
```bash
docker exec -i pipeline_postgres psql -U <SUPERUSER из ~/infrastructure/.env> -d postgres < infra/init-bit-flight-deck-db.sql
```
- [ ] **Step 5:** Verify — подключиться от имени нового пользователя:
```bash
docker exec -it pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "SELECT current_database(), current_user;"
```
Ожидание: возвращает строку `bit_flight_deck | bit_flight_deck_user`.
- [ ] **Step 6:** Commit:
```bash
git add infra/init-bit-flight-deck-db.sql .env.example
git commit -m "infra: add init script for bit_flight_deck DB and user"
```
(Файл `.env` НЕ коммитим — только `.env.example` с шаблоном.)
### Task 0.3: Создать `.env.example`
**Files:** Create: `.env.example`
- [ ] **Step 1:** Создать `.env.example` со всеми переменными которые понадобятся:
```
# PostgreSQL
PG_USER=bit_flight_deck_user
PG_PASSWORD=<set in .env>
PG_DB=bit_flight_deck
PG_HOST=pipeline_postgres
PG_PORT=5432
# BIT.RA HTTP-сервисы
BITRA_BASE_URL=http://<host>/<dbname>/hs/IntegrationAPI/v1
BITRA_USER=<api-user>
BITRA_PASSWORD=<set in .env>
# EVA Desk
EVA_BASE_URL=https://firstbit.evateam.ru/api/
EVA_ADMIN_TOKEN=<set in .env>
# Bitrix24
BITRIX_WEBHOOK_URL=https://vdst421.1cbit.ru/rest/91/<token>/
BITRIX_WEBHOOK_SECRET=<set in .env, в URL для outbound webhooks>
# Metabase
METABASE_SITE_NAME="bit-flight-deck"
METABASE_PORT=3001
# NocoDB
NOCODB_PORT=8090
NOCODB_ADMIN_EMAIL=roachesnokov@gmail.com
NOCODB_ADMIN_PASSWORD=<set in .env>
```
- [ ] **Step 2:** Commit:
```bash
git add .env.example
git commit -m "infra: add .env.example template"
```
### Task 0.4: Создать docker-compose с Metabase + NocoDB
**Files:** Create: `docker-compose.yml`
- [ ] **Step 1:** Создать `docker-compose.yml`:
```yaml
services:
metabase:
image: metabase/metabase:latest
container_name: bfd_metabase
restart: unless-stopped
ports:
- "${METABASE_PORT}:3000"
environment:
MB_DB_TYPE: postgres
MB_DB_DBNAME: ${PG_DB}
MB_DB_PORT: 5432
MB_DB_USER: ${PG_USER}
MB_DB_PASS: ${PG_PASSWORD}
MB_DB_HOST: ${PG_HOST}
MB_SITE_NAME: ${METABASE_SITE_NAME}
networks:
- pipeline_net
nocodb:
image: nocodb/nocodb:latest
container_name: bfd_nocodb
restart: unless-stopped
ports:
- "${NOCODB_PORT}:8080"
environment:
NC_DB: pg://${PG_HOST}:5432?u=${PG_USER}&p=${PG_PASSWORD}&d=${PG_DB}
NC_ADMIN_EMAIL: ${NOCODB_ADMIN_EMAIL}
NC_ADMIN_PASSWORD: ${NOCODB_ADMIN_PASSWORD}
networks:
- pipeline_net
networks:
pipeline_net:
external: true
```
- [ ] **Step 2:** Запустить:
```bash
cd ~/projects/bit-flight-deck/
docker compose up -d
```
- [ ] **Step 3:** Verify Metabase:
```bash
curl -s http://localhost:3001/api/health
```
Ожидание: `{"status":"ok"}`. Дать ~1 минуту на старт.
- [ ] **Step 4:** Verify NocoDB:
```bash
curl -s http://localhost:8090/api/v1/health
```
Ожидание: 200.
- [ ] **Step 5:** Открыть Metabase в браузере (http://localhost:3001), пройти initial setup wizard. Подключение к БД настроится автоматически через env-переменные.
- [ ] **Step 6:** Commit:
```bash
git add docker-compose.yml
git commit -m "infra: add Metabase + NocoDB docker-compose"
```
---
## Phase 1: Cloudflare Tunnel — поддомен n8n.bigmadnekenny.ru
### Task 1.1: Добавить ingress-route в cloudflared
**Files:**
- Modify: `/etc/cloudflared/config.yml` (на хосте, не в проекте)
- Create: `infra/cloudflared-routes.md` (документация)
- [ ] **Step 1:** Открыть существующий config cloudflared:
```bash
sudo cat /etc/cloudflared/config.yml
```
- [ ] **Step 2:** Добавить новый ingress перед `service: http_status:404`:
```yaml
- hostname: n8n.bigmadnekenny.ru
service: http://localhost:5678
```
- [ ] **Step 3:** Создать DNS-роут (через Cloudflare API или CLI):
```bash
cloudflared tunnel route dns <tunnel-name> n8n.bigmadnekenny.ru
```
Имя туннеля — посмотреть в `cloudflared tunnel list`.
- [ ] **Step 4:** Перезапустить сервис:
```bash
sudo systemctl restart cloudflared
sudo systemctl status cloudflared
```
Ожидание: `active (running)`.
- [ ] **Step 5:** Verify извне (например с телефона):
```bash
curl -I https://n8n.bigmadnekenny.ru/
```
Ожидание: HTTP 200 или 401 от N8N (зависит от настроек auth).
- [ ] **Step 6:** Создать документацию `infra/cloudflared-routes.md`:
```markdown
# Cloudflare Tunnel routes for bit-flight-deck
## n8n.bigmadnekenny.ru → localhost:5678 (N8N)
Назначение: приём outbound webhooks от Bitrix24 на N8N webhook-trigger.
URL для webhook'а Bitrix: https://n8n.bigmadnekenny.ru/webhook/bitrix/<secret-token>
Конфиг ingress в `/etc/cloudflared/config.yml`:
```yaml
- hostname: n8n.bigmadnekenny.ru
service: http://localhost:5678
```
```
- [ ] **Step 7:** Commit:
```bash
git add infra/cloudflared-routes.md
git commit -m "infra: document n8n.bigmadnekenny.ru cloudflared route"
```
---
## Phase 2: Database schemas
### Task 2.1: Создать схемы PostgreSQL
**Files:** Create: `sql/migrations/001_schemas.sql`
- [ ] **Step 1:** Создать файл с DDL:
```sql
-- 001_schemas.sql — базовые схемы для слоёв DWH
CREATE SCHEMA IF NOT EXISTS raw_bitra;
CREATE SCHEMA IF NOT EXISTS raw_eva;
CREATE SCHEMA IF NOT EXISTS raw_bitrix;
CREATE SCHEMA IF NOT EXISTS stg_bitra;
CREATE SCHEMA IF NOT EXISTS stg_eva;
CREATE SCHEMA IF NOT EXISTS stg_bitrix;
CREATE SCHEMA IF NOT EXISTS core;
CREATE SCHEMA IF NOT EXISTS mart;
-- Журнал применённых миграций
CREATE TABLE IF NOT EXISTS public.migrations (
filename text PRIMARY KEY,
applied_at timestamptz DEFAULT now()
);
```
- [ ] **Step 2:** Применить:
```bash
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck < sql/migrations/001_schemas.sql
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "INSERT INTO public.migrations (filename) VALUES ('001_schemas.sql');"
```
- [ ] **Step 3:** Verify:
```bash
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'raw_%' OR schema_name LIKE 'stg_%' OR schema_name IN ('core','mart');"
```
Ожидание: 8 строк.
- [ ] **Step 4:** Commit.
### Task 2.2: core.employee + department + history
**Files:** Create: `sql/migrations/002_core_employee.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.office (
id bigserial PRIMARY KEY,
name text NOT NULL,
bitra_id text UNIQUE
);
CREATE TABLE core.department (
id bigserial PRIMARY KEY,
name text NOT NULL,
bitra_id text UNIQUE,
bitrix_id bigint UNIQUE,
parent_id bigint REFERENCES core.department,
source text NOT NULL DEFAULT 'bitra' -- bitra | bitrix
);
CREATE TABLE core.employee (
id bigserial PRIMARY KEY,
email text UNIQUE NOT NULL,
full_name text,
first_name text,
last_name text,
bitra_user_id text UNIQUE,
eva_person_id text UNIQUE,
bitrix_user_id bigint UNIQUE,
rate decimal(10,2),
office_id bigint REFERENCES core.office,
department_id bigint REFERENCES core.department,
is_active boolean DEFAULT true,
is_target_for_mvp1 boolean DEFAULT false,
last_synced timestamptz
);
CREATE INDEX idx_employee_email ON core.employee (lower(email));
CREATE INDEX idx_employee_bitra_id ON core.employee (bitra_user_id);
CREATE INDEX idx_employee_eva_id ON core.employee (eva_person_id);
CREATE INDEX idx_employee_bitrix_id ON core.employee (bitrix_user_id);
CREATE TABLE core.department_history (
employee_id bigint REFERENCES core.employee,
valid_from date NOT NULL,
valid_to date,
department_id bigint REFERENCES core.department,
source text NOT NULL DEFAULT 'bitra'
);
```
- [ ] **Step 2:** Применить + записать в migrations.
- [ ] **Step 3:** Verify:
```bash
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "\dt core.*"
```
Ожидание: 4 таблицы.
- [ ] **Step 4:** Commit.
### Task 2.3: core.project + stage
**Files:** Create: `sql/migrations/003_core_project.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.project (
id bigserial PRIMARY KEY,
name text NOT NULL,
bitra_id text UNIQUE,
eva_id text UNIQUE,
bitra_code text,
eva_code text,
is_sd boolean DEFAULT false, -- SD-проект (из явного списка)
status text,
cache_status_type text, -- из EVA: OPEN/IN_PROGRESS/CLOSED
project_manager_id bigint REFERENCES core.employee,
client_id bigint, -- ID Битрикс-компании, без маппинга на BIT.RA-клиента
bitra_client_id text,
bitra_client_name text, -- для отображения
deadline date,
budget decimal(15,2)
);
CREATE INDEX idx_project_bitra ON core.project (bitra_id);
CREATE INDEX idx_project_eva ON core.project (eva_id);
CREATE TABLE core.stage (
id bigserial PRIMARY KEY,
name text NOT NULL,
bitra_id text UNIQUE,
project_id bigint REFERENCES core.project,
plan_start_date date,
plan_end_date date,
is_completed boolean DEFAULT false,
is_acted boolean DEFAULT false
);
CREATE INDEX idx_stage_project ON core.stage (project_id);
```
- [ ] **Step 2:** Apply + verify (\dt core.* — теперь 6 таблиц) + commit.
### Task 2.4: core.work_type + work_log
**Files:** Create: `sql/migrations/004_core_work_log.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.work_type (
code text PRIMARY KEY, -- ЛУРВ, ЛТ, Демо, ИТС, ИТСПлатныеРаботы, Сертификация, ...
label text NOT NULL,
category text NOT NULL CHECK (category IN ('commercial','presale','internal','free','ignored')),
is_billable boolean NOT NULL DEFAULT false
);
CREATE TABLE core.work_log (
id bigserial PRIMARY KEY,
employee_id bigint NOT NULL REFERENCES core.employee,
work_date date NOT NULL,
work_type_code text NOT NULL REFERENCES core.work_type,
project_id bigint REFERENCES core.project,
stage_id bigint REFERENCES core.stage,
bitra_client_id text, -- ID клиента BIT.RA, без core.client
bitra_client_name text,
hours decimal(10,2) NOT NULL,
description text,
bitra_doc_id text NOT NULL, -- ссылка на Document.Работы
bitra_row_index int NOT NULL,
UNIQUE (bitra_doc_id, bitra_row_index)
);
CREATE INDEX idx_work_log_employee_date ON core.work_log (employee_id, work_date DESC);
CREATE INDEX idx_work_log_project ON core.work_log (project_id);
CREATE INDEX idx_work_log_work_type ON core.work_log (work_type_code);
```
- [ ] **Step 2:** Apply + verify + commit.
### Task 2.5: core.task
**Files:** Create: `sql/migrations/005_core_task.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.task (
id bigserial PRIMARY KEY,
eva_id text UNIQUE NOT NULL, -- CmfTask:UUID
code text, -- PBSD-12582
name text,
project_id bigint REFERENCES core.project,
responsible_id bigint REFERENCES core.employee,
cache_status_type text NOT NULL CHECK (cache_status_type IN ('OPEN','IN_PROGRESS','IN_REVIEW','CLOSED')),
eva_status_id text,
eva_status_name text,
cmf_created_at timestamptz,
cmf_modified_at timestamptz,
status_in_progress_start timestamptz,
deadline timestamptz,
last_synced timestamptz
);
CREATE INDEX idx_task_responsible ON core.task (responsible_id);
CREATE INDEX idx_task_status ON core.task (cache_status_type);
CREATE INDEX idx_task_project ON core.task (project_id);
```
- [ ] **Step 2:** Apply + commit.
### Task 2.6: core.deal + deal_team_member
**Files:** Create: `sql/migrations/006_core_deal.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.deal (
id bigserial PRIMARY KEY,
bitrix_id bigint UNIQUE NOT NULL,
title text,
category_id int, -- 16
stage_id text, -- C16:FINAL_INVOICE
stage_semantic_id char(1), -- P | S | F
opportunity decimal(15,2),
begindate date,
closedate date,
assigned_to_id bigint REFERENCES core.employee,
bitrix_company_id bigint,
bitrix_company_name text,
project_manager_id bigint REFERENCES core.employee,
is_in_forecast boolean DEFAULT false, -- попадает ли в mart.workload_forecast
last_synced timestamptz
);
CREATE INDEX idx_deal_stage ON core.deal (stage_id);
CREATE INDEX idx_deal_forecast ON core.deal (is_in_forecast) WHERE is_in_forecast = true;
CREATE TABLE core.deal_team_member (
deal_id bigint REFERENCES core.deal ON DELETE CASCADE,
employee_id bigint REFERENCES core.employee,
weight decimal(5,2) NOT NULL DEFAULT 1.0,
is_manual_override boolean DEFAULT false,
PRIMARY KEY (deal_id, employee_id)
);
CREATE INDEX idx_deal_team_employee ON core.deal_team_member (employee_id);
```
- [ ] **Step 2:** Apply + commit.
### Task 2.7: core.identity_map
**Files:** Create: `sql/migrations/007_core_identity_map.sql`
- [ ] **Step 1:** DDL:
```sql
CREATE TABLE core.identity_map (
id bigserial PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('employee','project','client')),
core_id bigint,
bitra_id text,
eva_id text,
bitrix_id bigint,
confidence text NOT NULL CHECK (confidence IN ('auto','confirmed','manual')),
match_key text, -- например email или EVA_ID
confirmed_by text,
confirmed_at timestamptz,
created_at timestamptz DEFAULT now(),
UNIQUE (entity_type, bitra_id, eva_id, bitrix_id)
);
CREATE INDEX idx_identity_map_core ON core.identity_map (entity_type, core_id);
CREATE INDEX idx_identity_map_confidence ON core.identity_map (confidence) WHERE confidence = 'manual';
```
- [ ] **Step 2:** Apply + commit.
### Task 2.8: raw schemas
**Files:** Create: `sql/migrations/008_raw_schemas.sql`
- [ ] **Step 1:** DDL — таблицы-сейфы для JSONB-снимков:
```sql
-- BIT.RA raw
CREATE TABLE raw_bitra.employees (
bitra_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitra.works (
bitra_doc_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitra.projects (
bitra_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitra.dictionaries (
kind text NOT NULL, -- 'office' | 'department' | 'manager' | 'stage' | 'config' | 'contract'
bitra_id text NOT NULL,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now(),
PRIMARY KEY (kind, bitra_id)
);
CREATE TABLE raw_bitra.work_types (
bitra_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitra.dept_history (
bitra_employee_id text NOT NULL,
period date NOT NULL,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now(),
PRIMARY KEY (bitra_employee_id, period)
);
-- EVA raw
CREATE TABLE raw_eva.persons (
eva_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_eva.projects (
eva_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_eva.tasks (
eva_id text PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
-- Bitrix raw
CREATE TABLE raw_bitrix.deals (
bitrix_id bigint PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitrix.users (
bitrix_id bigint PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
CREATE TABLE raw_bitrix.departments (
bitrix_id bigint PRIMARY KEY,
payload jsonb NOT NULL,
synced_at timestamptz DEFAULT now()
);
-- Журнал синхронизаций
CREATE TABLE raw_bitra.sync_log (
source text NOT NULL,
entity text NOT NULL,
last_sync_ts timestamptz,
records_count int,
status text,
error_message text,
synced_at timestamptz DEFAULT now()
);
CREATE INDEX idx_sync_log_source_entity ON raw_bitra.sync_log (source, entity, synced_at DESC);
```
- [ ] **Step 2:** Apply + verify через `\dt raw_*.*` + commit.
### Task 2.9: stg schemas (плоские нормализованные)
**Files:** Create: `sql/migrations/009_stg_schemas.sql`
stg-слой — это **views** на raw. Они материализуются как обычные SELECT-запросы. Никаких CREATE TABLE — только VIEW. Это значит структура определяется в `sql/views/`, а в миграции — только пустышка-плейсхолдер для документации.
- [ ] **Step 1:** Создать миграцию-документ:
```sql
-- 009_stg_schemas.sql
-- stg-слой реализован как VIEW в sql/views/stg_*.sql.
-- Эта миграция — placeholder для трекинга порядка применения.
SELECT 1;
```
- [ ] **Step 2:** Apply + commit.
### Task 2.10: Seed work_types
**Files:** Create: `sql/seed/work_types.sql`
- [ ] **Step 1:** Полный seed по [reference-bitra-work-types](../../memory/reference_bitra_work_types.md) (14 значений из Enum):
```sql
INSERT INTO core.work_type (code, label, category, is_billable) VALUES
('ЛУРВ', 'ЛУРВ (платно)', 'commercial', true),
('ЛТ', 'ЛТ (платно)', 'commercial', true),
('Демо', 'Демо (Пресейл)', 'presale', false),
('ИТС', 'ИТС (договор)', 'commercial', true),
('ИТСПлатныеРаботы', 'ИТС (доп. услуги)', 'commercial', true),
('Сертификация', 'Сертификация', 'internal', false),
('Внутреннее', 'Обучение (кроме сертификации)', 'internal', false),
('НеОпл', 'Бесплатные часы в счёт ПП', 'free', false),
('Установка', 'Установка в счёт ПП', 'free', false),
('Гарантия', 'Гарантия (бесплатно)', 'free', false),
('Управленка', 'Работа руководителя', 'internal', false),
('ВнутренниеРаботы', 'Внутренние работы', 'internal', false),
('Коробка', 'Коробка (рудимент)', 'ignored', false),
('Отложено', 'Отложено (рудимент)', 'ignored', false)
ON CONFLICT (code) DO UPDATE SET
label = EXCLUDED.label,
category = EXCLUDED.category,
is_billable = EXCLUDED.is_billable;
```
- [ ] **Step 2:** Apply + verify:
```bash
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "SELECT category, count(*) FROM core.work_type GROUP BY category;"
```
Ожидание: 5 строк (commercial=4, presale=1, internal=4, free=3, ignored=2).
- [ ] **Step 3:** Commit.
---
## Phase 3: BIT.RA HTTP-сервисы
> **ВАЖНО:** эта фаза — 1С-разработка внутри BIT.RA. Делается через **EDT** или **Конфигуратор 1С**. Тестирование — через curl/Postman. Версионирование кода 1С — отдельная задача (например через `db-dump-xml` skill).
### Task 3.1: Создать пользователя API в BIT.RA
**Files:** (внутри 1С)
- [ ] **Step 1:** В BIT.RA через Конфигуратор создать пользователя `bit_flight_deck_api` с минимальными правами (только чтение релевантных объектов из спецификации).
- [ ] **Step 2:** Назначить роль (создать новую) `bit_flight_deck_API_Чтение` с правами:
- Чтение для `Catalog.Пользователи`, `Catalog.Подразделение`, `Catalog.Офис`, `Catalog.Менеджеры`, `Catalog.Клиенты`, `Catalog.Проекты`, `Catalog.ЭтапыПроектов`, `Catalog.Конфигурации`, `Catalog.Договоры`, `Catalog.СценарииПланирования`.
- Чтение для `Document.Работы`.
- Чтение для `AccumulationRegister.Работы`, `InformationRegister.ПодразделениеСотрудников`.
- Чтение для `Enum.ВидыРабот`.
- [ ] **Step 3:** Установить пароль, записать в `.env` как `BITRA_PASSWORD`.
### Task 3.2: Создать общий модуль IntegrationAPI
**Files:** в BIT.RA — `CommonModule.IntegrationAPI`
- [ ] **Step 1:** В Конфигураторе создать `Общий модуль → IntegrationAPI`. Установить флаги: «Сервер», «Вызов сервера», «Внешнее соединение».
- [ ] **Step 2:** Создать процедуру-хелпер для возврата JSON:
```bsl
Функция СформироватьОтвет(Данные) Экспорт
ЗаписьJSON = Новый ЗаписьJSON;
ПараметрыJSON = Новый ПараметрыЗаписиJSON(ПереносСтрокJSON.БезПереносов);
ЗаписьJSON.УстановитьСтроку(ПараметрыJSON);
ЗаписатьJSON(ЗаписьJSON, Данные);
Ответ = Новый HTTPСервисОтвет(200);
Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
Ответ.УстановитьТелоИзСтроки(ЗаписьJSON.Закрыть());
Возврат Ответ;
КонецФункции
```
### Task 3.3: HTTP-сервис /api/employees
**Files:** в BIT.RA — `HTTPService.IntegrationAPI` (или модифицировать существующий)
- [ ] **Step 1:** Создать HTTP-сервис `IntegrationAPI` с корневым URL `/v1`.
- [ ] **Step 2:** Шаблон URL `/employees` с методом GET. Обработчик:
```bsl
Функция ПолучитьСотрудников(Запрос)
МодифицированоПосле = Запрос.ПараметрыЗапроса.Получить("modified_since");
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| Пользователи.Ссылка КАК Идентификатор,
| Пользователи.Наименование КАК ФИО,
| Пользователи.EVA_ID КАК EvaID,
| Пользователи.EVA_Токен КАК EvaТокен,
| Пользователи.Офис.Наименование КАК Офис,
| Пользователи.Подразделение КАК Подразделение,
| Пользователи.Ставка КАК Ставка,
| Пользователи.Недействителен КАК Недействителен,
| Пользователи.ДолженЗаполнятьОтчет КАК ДолженЗаполнятьОтчет,
| Пользователи.ОтчетПродажиПроектов КАК ОтчетПродажиПроектов,
| Пользователи.КонтактнаяИнформация.(
| Вид.Наименование КАК ВидКИ,
| Представление КАК Значение
| ) КАК КонтактнаяИнформация
|ИЗ
| Справочник.Пользователи КАК Пользователи";
// TODO: фильтр по modified_since если потребуется (для пользователей редко)
Результат = Новый Массив;
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
Email = "";
КИВыборка = Выборка.КонтактнаяИнформация.Выбрать();
Пока КИВыборка.Следующий() Цикл
Если КИВыборка.ВидКИ = "Email" Или СтрНайти(КИВыборка.ВидКИ, "Email") > 0 Тогда
Email = КИВыборка.Значение;
Прервать;
КонецЕсли;
КонецЦикла;
Запись = Новый Структура;
Запись.Вставить("id", Строка(Выборка.Идентификатор.УникальныйИдентификатор()));
Запись.Вставить("full_name", Выборка.ФИО);
Запись.Вставить("email", Email);
Запись.Вставить("eva_id", Выборка.EvaID);
Запись.Вставить("office", Выборка.Офис);
Запись.Вставить("department", Строка(Выборка.Подразделение));
Запись.Вставить("rate", Выборка.Ставка);
Запись.Вставить("is_active", НЕ Выборка.Недействителен);
Запись.Вставить("should_fill_report", Выборка.ДолженЗаполнятьОтчет);
Результат.Добавить(Запись);
КонецЦикла;
Возврат IntegrationAPI.СформироватьОтвет(Результат);
КонецФункции
```
- [ ] **Step 3:** Опубликовать на веб-сервере (Apache). Использовать существующий механизм публикации BIT.RA (см. cf-1c-skills `web-publish` если нужно).
- [ ] **Step 4:** Тест curl:
```bash
curl -u bit_flight_deck_api:<password> http://<host>/<dbname>/hs/IntegrationAPI/v1/employees | jq '.[0]'
```
Ожидание: JSON с полями id, full_name, email и др.
- [ ] **Step 5:** Закоммитить spec и описание в репозиторий — добавить в `docs/superpowers/specs/2026-05-13-mvp1-workload-design.md` (или отдельным файлом) конкретные имена эндпоинтов и форматы ответов.
### Task 3.4: HTTP-сервис /api/works
**Files:** в BIT.RA — расширить тот же HTTPService
- [ ] **Step 1:** Шаблон URL `/works` с GET. Поддержка query param `modified_since` (timestamp ISO 8601).
- [ ] **Step 2:** Запрос — `Document.Работы` с табличной частью «Работы», фильтрация по `Дата >= modified_since`:
```bsl
Запрос.Текст =
"ВЫБРАТЬ
| Работы.Ссылка КАК ДокИдентификатор,
| Работы.Номер КАК Номер,
| Работы.Дата КАК Дата,
| Работы.Исполнитель КАК Исполнитель,
| Работы.Подразделение КАК Подразделение,
| Работы.Офис КАК Офис,
| Работы.Утвержден КАК Утвержден,
| Работы.Работы.(
| НомерСтроки КАК НомерСтроки,
| СодержаниеРабот КАК Содержание,
| КоличествоЧасов КАК Часы,
| ВидРаботы КАК ВидРаботы,
| Клиент КАК Клиент,
| Клиент.Наименование КАК КлиентИмя,
| Менеджер КАК Менеджер,
| Проект КАК Проект,
| Этап КАК Этап,
| НомерЗаявки КАК НомерЗаявки,
| ЛТ КАК ЛТ,
| РаботаВыполнена КАК РаботаВыполнена
| ) КАК Строки
|ИЗ
| Документ.Работы КАК Работы
|ГДЕ
| Работы.Дата >= &МодифицированоПосле";
Запрос.УстановитьПараметр("МодифицированоПосле", ?(МодифицированоПосле = Неопределено, '00010101', Дата(МодифицированоПосле)));
```
Сериализация в JSON — аналогично employees, с массивом строк ТЧ.
- [ ] **Step 2.5:** Сериализация:
```bsl
// ... после выполнения запроса:
Результат = Новый Массив;
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
ЗаписьДок = Новый Структура;
ЗаписьДок.Вставить("id", Строка(Выборка.ДокИдентификатор.УникальныйИдентификатор()));
ЗаписьДок.Вставить("number", Выборка.Номер);
ЗаписьДок.Вставить("date", Формат(Выборка.Дата, "ДФ=yyyy-MM-dd"));
ЗаписьДок.Вставить("employee_id", Строка(Выборка.Исполнитель.УникальныйИдентификатор()));
ЗаписьДок.Вставить("department", Строка(Выборка.Подразделение));
ЗаписьДок.Вставить("office", Строка(Выборка.Офис));
ЗаписьДок.Вставить("approved", Выборка.Утвержден);
Строки = Новый Массив;
ВыборкаСтрок = Выборка.Строки.Выбрать();
Пока ВыборкаСтрок.Следующий() Цикл
Строка = Новый Структура;
Строка.Вставить("row_index", ВыборкаСтрок.НомерСтроки);
Строка.Вставить("description", ВыборкаСтрок.Содержание);
Строка.Вставить("hours", ВыборкаСтрок.Часы);
Строка.Вставить("work_type", Строка(ВыборкаСтрок.ВидРаботы));
Строка.Вставить("client_id", ?(ЗначениеЗаполнено(ВыборкаСтрок.Клиент), Строка(ВыборкаСтрок.Клиент.УникальныйИдентификатор()), ""));
Строка.Вставить("client_name", ВыборкаСтрок.КлиентИмя);
Строка.Вставить("project_id", ?(ЗначениеЗаполнено(ВыборкаСтрок.Проект), Строка(ВыборкаСтрок.Проект.УникальныйИдентификатор()), ""));
Строка.Вставить("stage_id", ?(ЗначениеЗаполнено(ВыборкаСтрок.Этап), Строка(ВыборкаСтрок.Этап.УникальныйИдентификатор()), ""));
Строка.Вставить("request_number", ВыборкаСтрок.НомерЗаявки);
Строка.Вставить("lt_id", ?(ЗначениеЗаполнено(ВыборкаСтрок.ЛТ), Строка(ВыборкаСтрок.ЛТ.УникальныйИдентификатор()), ""));
Строка.Вставить("work_done", ВыборкаСтрок.РаботаВыполнена);
Строки.Добавить(Строка);
КонецЦикла;
ЗаписьДок.Вставить("rows", Строки);
Результат.Добавить(ЗаписьДок);
КонецЦикла;
Возврат IntegrationAPI.СформироватьОтвет(Результат);
```
- [ ] **Step 3:** Тест:
```bash
curl -u bit_flight_deck_api:<password> "http://<host>/<dbname>/hs/IntegrationAPI/v1/works?modified_since=2026-05-01" | jq 'length, .[0].rows | length'
```
Ожидание: число документов и число строк.
- [ ] **Step 4:** Закоммитить в `n8n/workflows/01-pull-bitra-works.json` ожидаемый формат (потом).
### Task 3.53.8: HTTP-сервисы /api/projects, /api/dictionaries, /api/work_types, /api/dept_history
(структура аналогична Task 3.3-3.4: запрос, сериализация JSON, тест curl).
Минимальные требования:
- `/api/projects` — Catalog.Проекты с реквизитами (Конфигурация, РуководительПроекта, МенеджерПроекта, Клиент, Договор, ДатаСтарта, ДатаФиниш, Бюджет, EVA_ID, СтатусПроекта).
- `/api/dictionaries` — JSON-объект с ключами `office`, `department`, `manager`, `stage`, `config`, `contract` — каждый массив объектов из соответствующего справочника.
- `/api/work_types` — Enum.ВидыРабот: `[{"code":"ЛУРВ","label":"ЛУРВ (платно)","order":...}, ...]`.
- `/api/dept_history` — записи `InformationRegister.ПодразделениеСотрудников` с фильтром по `Период >= modified_since`.
Для каждого — отдельный коммит.
---
## Phase 4: N8N pull workflows для BIT.RA
> **Workflows создаются в UI N8N (http://localhost:5678), экспортируются как JSON и коммитятся в `n8n/workflows/`.**
### Task 4.1: Workflow «Pull BIT.RA employees»
**Files:** Create: `n8n/workflows/01-pull-bitra-employees.json` (после экспорта)
- [ ] **Step 1:** В N8N создать новый workflow. Добавить ноду **Schedule Trigger** (cron `0 */6 * * *` — раз в 6 часов).
- [ ] **Step 2:** Добавить ноду **HTTP Request** (GET):
- URL: `{{$env.BITRA_BASE_URL}}/employees`
- Authentication: Basic Auth (`BITRA_USER` / `BITRA_PASSWORD`).
- Response Format: JSON.
- [ ] **Step 3:** Добавить ноду **Postgres** с операцией Insert/Update:
- Credentials: `pipeline_postgres` (использовать DB `bit_flight_deck` + `bit_flight_deck_user`).
- SQL:
```sql
INSERT INTO raw_bitra.employees (bitra_id, payload, synced_at)
SELECT
elem->>'id' AS bitra_id,
elem AS payload,
now() AS synced_at
FROM json_array_elements($1::json) AS elem
ON CONFLICT (bitra_id) DO UPDATE SET payload = EXCLUDED.payload, synced_at = now();
```
- Параметр $1 — JSON-массив от HTTP-узла.
- [ ] **Step 4:** Тестовый запуск (Execute Workflow). Verify:
```bash
docker exec -i pipeline_postgres psql -U bit_flight_deck_user -d bit_flight_deck -c "SELECT count(*) FROM raw_bitra.employees;"
```
Ожидание: > 0.
- [ ] **Step 5:** Активировать workflow.
- [ ] **Step 6:** Экспорт workflow в JSON (Settings → Download). Сохранить как `n8n/workflows/01-pull-bitra-employees.json`. Commit.
### Task 4.24.6: Workflows для BIT.RA works, projects, dictionaries, work_types, dept_history
Аналогично 4.1. Для works/dept_history дополнительно передавать `modified_since` — последний `max(synced_at)` из соответствующей таблицы:
- Перед HTTP-нодой добавить **Postgres-узел** SELECT: `SELECT coalesce(max(synced_at), '2020-01-01') FROM raw_bitra.<table>`.
- Этот таймстамп подставлять в URL HTTP-узла.
Каждый workflow:
- Schedule trigger (`*/30 * * * *` для works — каждые 30 мин; раз в сутки для редко меняющихся).
- HTTP request с auth.
- Postgres upsert в соответствующую raw-таблицу.
- Запись в `raw_bitra.sync_log`.
---
## Phase 5: N8N pull EVA
### Task 5.1: Workflow «Pull EVA nightly full»
**Files:** Create: `n8n/workflows/05-pull-eva-nightly-full.json`
- [ ] **Step 1:** Schedule trigger — `0 2 * * *` (раз в сутки в 02:00).
- [ ] **Step 2:** Для каждой сущности (CmfPerson, CmfProject, CmfTask, CmfStatus, CmfStatusHistory) — HTTP Request POST:
- URL: `https://firstbit.evateam.ru/api/`
- Headers: `Authorization: Bearer {{$env.EVA_ADMIN_TOKEN}}`, `Content-Type: application/json`
- Body (JSON):
```json
{
"jsonrpc": "2.2",
"method": "CmfPerson.list",
"callid": "{{$workflow.id}}-{{$execution.id}}",
"kwargs": {
"fields": ["id","login","email","name","first_name","last_name","does_not_work","cmf_archived","work_position","primary_role_id","calendar_id"],
"slice": [0, 1000]
}
}
```
- [ ] **Step 3:** Postgres upsert в `raw_eva.persons` (аналогично BIT.RA).
- [ ] **Step 4:** Повторить для projects, tasks, status_history. Параметризовать через split-into-batches если задач много (27k).
- [ ] **Step 5:** Verify count в raw_eva.* > 0. Export workflow. Commit.
### Task 5.2: Workflow «Pull EVA incremental via CmfAudit»
**Files:** Create: `n8n/workflows/04-pull-eva-incremental.json`
- [ ] **Step 1:** Schedule — `*/30 * * * *` (каждые 30 минут).
- [ ] **Step 2:** Postgres SELECT — `SELECT coalesce(max(audit_at), now() - interval '1 day') FROM raw_eva.sync_log WHERE source='eva' AND entity='audit'`.
- [ ] **Step 3:** HTTP POST на `CmfAudit.list` с фильтром `cmf_created_at >= <last_sync>`. Получаем список ID + class_name.
- [ ] **Step 4:** Split по class_name. Для каждого — соответствующий `.get` по id.
- [ ] **Step 5:** Запись в raw_eva.{persons,projects,tasks} в зависимости от class_name.
- [ ] **Step 6:** Update sync_log. Export. Commit.
---
## Phase 6: N8N Bitrix24 — webhooks + reconcile
### Task 6.1: Workflow «Bitrix webhook handler»
**Files:** Create: `n8n/workflows/06-bitrix-webhook-handler.json`
- [ ] **Step 1:** Добавить **Webhook trigger** ноду. Method POST. Path: `/webhook/bitrix/<secret-token>` (token из `.env`).
- [ ] **Step 2:** В настройках workflow добавить **Production URL** — `https://n8n.bigmadnekenny.ru/webhook/bitrix/<secret-token>`.
- [ ] **Step 3:** Body распарсить — Bitrix передаёт `event`, `data[FIELDS][ID]`. Извлечь deal_id или user_id.
- [ ] **Step 4:** Switch-нода по event:
- `ONCRMDEALADD|UPDATE|DELETE` → следующая ветка для сделок.
- `ONUSERADD|UPDATE|DELETE` → ветка для пользователей.
- [ ] **Step 5:** Для сделок: HTTP GET `{{$env.BITRIX_WEBHOOK_URL}}crm.deal.get.json?id=<deal_id>` со всеми UF-полями.
- [ ] **Step 6:** Postgres upsert в `raw_bitrix.deals`.
- [ ] **Step 7:** Для пользователей: `user.get.json?ID=<user_id>` + upsert в `raw_bitrix.users`.
- [ ] **Step 8:** В Bitrix создать outbound webhooks (в админ-панели → Разработчикам → Исходящие вебхуки → создать), указав URL: `https://n8n.bigmadnekenny.ru/webhook/bitrix/<secret-token>`. События: `ONCRMDEALADD/UPDATE/DELETE`, `ONUSERADD/UPDATE/DELETE`.
- [ ] **Step 9:** Verify — изменить тестовую сделку в Битриксе → через 1-2 минуты в raw_bitrix.deals должна появиться запись.
- [ ] **Step 10:** Export. Commit.
### Task 6.2: Workflow «Bitrix nightly reconcile»
**Files:** Create: `n8n/workflows/07-pull-bitrix-nightly-reconcile.json`
- [ ] **Step 1:** Schedule `0 3 * * *` (03:00).
- [ ] **Step 2:** HTTP POST на `{{$env.BITRIX_WEBHOOK_URL}}crm.deal.list.json` с filter `[CATEGORY_ID]=16` + select `[*, UF_*]` + пагинация (`start`).
- [ ] **Step 3:** Postgres upsert в `raw_bitrix.deals`.
- [ ] **Step 4:** Параллельно — `user.get.json?FILTER[ACTIVE]=Y&FILTER[USER_TYPE]=employee` → `raw_bitrix.users`.
- [ ] **Step 5:** `department.get.json` → `raw_bitrix.departments`.
- [ ] **Step 6:** Export. Commit.
---
## Phase 7: SQL Transformations
### Task 7.1: Views stg_bitra.*
**Files:** Create: `sql/views/stg_bitra_employee.sql`
- [ ] **Step 1:** Создать view:
```sql
CREATE OR REPLACE VIEW stg_bitra.employee AS
SELECT
payload->>'id' AS bitra_id,
payload->>'full_name' AS full_name,
lower(nullif(payload->>'email', '')) AS email,
nullif(payload->>'eva_id', '') AS eva_id,
payload->>'office' AS office,
payload->>'department' AS department,
(payload->>'rate')::decimal(10,2) AS rate,
(payload->>'is_active')::boolean AS is_active,
(payload->>'should_fill_report')::boolean AS should_fill_report,
synced_at
FROM raw_bitra.employees;
```
- [ ] **Step 2:** Apply (psql) + verify `SELECT count(*) FROM stg_bitra.employee;`.
- [ ] **Step 3:** Commit.
### Task 7.27.6: Аналогичные views для stg_bitra.works, stg_eva.person, stg_eva.task, stg_eva.project, stg_bitrix.deal, stg_bitrix.user
Каждая — SQL-view, извлекающая поля из JSONB payload в плоские колонки.
### Task 7.7: Stored procedure `core.merge_employee()`
**Files:** Create: `sql/procedures/core_merge_employee.sql`
- [ ] **Step 1:**
```sql
CREATE OR REPLACE FUNCTION core.merge_employee() RETURNS void AS $$
BEGIN
-- 1. Upsert по bitra_id, identity_map auto на email
INSERT INTO core.employee (
email, full_name, bitra_user_id, rate, is_active, last_synced
)
SELECT DISTINCT ON (lower(b.email))
b.email,
b.full_name,
b.bitra_id,
b.rate,
b.is_active,
now()
FROM stg_bitra.employee b
WHERE b.email IS NOT NULL
ON CONFLICT (email) DO UPDATE SET
full_name = EXCLUDED.full_name,
bitra_user_id = EXCLUDED.bitra_user_id,
rate = EXCLUDED.rate,
is_active = EXCLUDED.is_active,
last_synced = now();
-- 2. Дополнить EVA-id для тех у кого есть email-mapping
UPDATE core.employee ce
SET eva_person_id = e.eva_id
FROM stg_eva.person e
WHERE lower(ce.email) = lower(e.login)
AND ce.eva_person_id IS DISTINCT FROM e.eva_id;
-- 3. Дополнить Bitrix-id
UPDATE core.employee ce
SET bitrix_user_id = u.bitrix_id
FROM stg_bitrix.user u
WHERE lower(ce.email) = lower(u.email)
AND ce.bitrix_user_id IS DISTINCT FROM u.bitrix_id;
-- 4. identity_map записи для unmatched сотрудников (auto-confidence)
-- (упрощено для MVP: ручное разрешение через NocoDB)
END;
$$ LANGUAGE plpgsql;
```
- [ ] **Step 2:** Apply + тест:
```sql
SELECT core.merge_employee();
SELECT count(*) FROM core.employee;
```
- [ ] **Step 3:** Commit.
### Task 7.87.12: Аналогичные procedures для project, work_log, task, deal
Каждая — SP, которая делает merge из stg в core.
### Task 7.13: Mart view `mart.workload_actual`
**Files:** Create: `sql/views/mart_workload_actual.sql`
- [ ] **Step 1:**
```sql
CREATE OR REPLACE VIEW mart.workload_actual AS
WITH last30 AS (
SELECT
w.employee_id,
sum(w.hours) FILTER (WHERE wt.category = 'commercial') AS hours_commercial,
sum(w.hours) AS hours_total,
sum(w.hours) FILTER (WHERE wt.category = 'commercial')::decimal
/ nullif(sum(w.hours), 0) * 100 AS pct_commercial
FROM core.work_log w
JOIN core.work_type wt ON wt.code = w.work_type_code
WHERE w.work_date >= current_date - interval '30 days'
AND wt.category != 'ignored'
GROUP BY w.employee_id
)
SELECT
e.id AS employee_id,
e.full_name,
e.email,
d.name AS department,
o.name AS office,
coalesce(l.hours_commercial, 0) AS hours_commercial_30d,
coalesce(l.hours_total, 0) AS hours_total_30d,
coalesce(l.pct_commercial, 0) AS pct_commercial_30d
FROM core.employee e
LEFT JOIN core.department d ON d.id = e.department_id
LEFT JOIN core.office o ON o.id = e.office_id
LEFT JOIN last30 l ON l.employee_id = e.id
WHERE e.is_active = true
AND e.is_target_for_mvp1 = true;
```
- [ ] **Step 2:** Apply + verify count > 0 (после первой синхронизации) + commit.
### Task 7.147.16: Mart views для current/planned/forecast
- `mart.workload_current` — задачи EVA в IN_PROGRESS с responsible_id.
- `mart.workload_planned` — задачи EVA в OPEN + IN_PROGRESS с responsible_id («осталось»).
- `mart.workload_forecast` — сделки Bitrix CAT=16 в стадиях forecast (см. seed) с распределением через deal_team_member.weight.
### Task 7.17: N8N workflow «Trigger transforms»
**Files:** Create: `n8n/workflows/08-trigger-transforms.json`
- [ ] **Step 1:** Schedule `*/10 * * * *` (каждые 10 мин).
- [ ] **Step 2:** Postgres-нода:
```sql
SELECT core.merge_employee();
SELECT core.merge_project();
SELECT core.merge_work_log();
SELECT core.merge_task();
SELECT core.merge_deal();
```
- [ ] **Step 3:** Export. Commit.
---
## Phase 8: Metabase dashboard
### Task 8.1: Подключить БД к Metabase
- [ ] **Step 1:** Открыть http://localhost:3001 → Admin → Databases → Add.
- [ ] **Step 2:** Host: `pipeline_postgres`, Port: 5432, DB: `bit_flight_deck`, User: `bit_flight_deck_user`.
- [ ] **Step 3:** Тест connection — должно быть OK.
### Task 8.2: Создать дашборд «Загрузка сотрудников MVP-1»
- [ ] **Step 1:** Создать новый Dashboard «Загрузка сотрудников MVP-1».
- [ ] **Step 2:** Добавить виджеты:
1. **Plate**: «Средний % коммерческой загрузки за 30д» — SQL: `SELECT avg(pct_commercial_30d) FROM mart.workload_actual;`
2. **Plate**: «N безхозных задач EVA» — SQL: `SELECT count(*) FROM mart.tasks_orphan;`
3. **Table**: «Загрузка по сотрудникам» — SELECT по composite view `mart.workload_summary` со всеми 4 слоями.
4. **Line chart**: «Динамика % коммерческой загрузки» за 90 дней по неделям.
5. **Bar chart**: «Топ свободных» и «Топ перегруженных».
- [ ] **Step 3:** Применить условное форматирование (красный/жёлтый/зелёный по pct_commercial).
- [ ] **Step 4:** Сохранить дашборд, экспортировать в JSON (Metabase serialization API или вручную через UI) → `n8n/workflows/metabase-dashboard.json` или отдельная папка `metabase/`.
- [ ] **Step 5:** Commit.
---
## Phase 9: NocoDB Admin UI
### Task 9.1: Подключить NocoDB к БД
- [ ] **Step 1:** Открыть http://localhost:8090, login.
- [ ] **Step 2:** Создать Base → External Database → PostgreSQL → connection params как у Metabase.
### Task 9.2: Создать представления
- [ ] **Step 1:** Создать таблицу `core.identity_map` в NocoDB как Linked Table.
- [ ] **Step 2:** Filter view «Identity issues» — `confidence = 'manual'`.
- [ ] **Step 3:** Создать таблицу `core.deal_team_member` с editable `weight` и `is_manual_override`.
- [ ] **Step 4:** Сохранить view configs.
---
## Phase 10: Bootstrap identity и acceptance
### Task 10.1: Запустить initial sync
- [ ] **Step 1:** Запустить вручную все workflows BIT.RA в N8N (Execute Workflow).
- [ ] **Step 2:** Запустить EVA nightly full.
- [ ] **Step 3:** Запустить Bitrix nightly reconcile.
- [ ] **Step 4:** Запустить трансформации (`core.merge_*`).
### Task 10.2: Промаркировать ~20 сотрудников MVP-1
- [ ] **Step 1:** Получить от пользователя список email или department-id.
- [ ] **Step 2:** SQL update:
```sql
UPDATE core.employee
SET is_target_for_mvp1 = true
WHERE lower(email) IN ('user1@1cbit.ru', 'user2@1cbit.ru', ...);
```
- [ ] **Step 3:** Verify через дашборд — таблица должна показать ~20 строк.
### Task 10.3: Acceptance — все 8 критериев из спеки
- [ ] **Step 1:** Проверить каждое acceptance criterion из секции 11 [spec](../specs/2026-05-13-mvp1-workload-design.md).
- [ ] **Step 2:** Зафиксировать результаты в `docs/superpowers/specs/2026-05-13-mvp1-workload-design.md` (раздел Status → Live).
### Task 10.4: Финальный коммит и тэг
- [ ] **Step 1:** `git tag -a mvp1-v1.0 -m "MVP-1 workload — first production release"`
- [ ] **Step 2:** `git push --tags`
---
## Self-Review
### Spec coverage
- ✅ Section 1 (Контекст) → не требует имплементации, документ.
- ✅ Section 2 (Цели/не-цели) → ограничено scope; Phase 0-10 покрывает цели.
- ✅ Section 3 (Бизнес-вопросы и витрины) → Phase 7 mart views + Phase 8 дашборд.
- ✅ Section 4 (Архитектура) → Phase 0 (Metabase/NocoDB), Phase 1 (CF Tunnel), Phase 2 (PG), Phase 3 (BIT.RA), Phase 4-6 (N8N).
- ✅ Section 5 (Транспорт) → Phase 3-6.
- ✅ Section 6 (Data Model) → Phase 2 (core DDL), Phase 7 (mart views).
- ✅ Section 7 (Identity-resolution) → Phase 7.7 (core.merge_employee), Phase 9 (NocoDB UI).
- ✅ Section 8 (SQL-трансформации) → Phase 7.
- ✅ Section 9 (Дашборд) → Phase 8.
- ✅ Section 10 (Operational) → задеваем; backup упоминаем как ручной cron.
- ✅ Section 11 (Acceptance) → Phase 10.3.
- ✅ Section 12 (Параллельные орг-задачи) → задача пользователя, не код.
- ✅ Section 13 (Open Questions) → задачи пользователя (whitelist сотрудников, SD-проекты, стадии Битрикса).
- ✅ Section 14 (Backlog) → не в этом плане, по дизайну.
### Placeholder scan
- В Task 3.5-3.8 написано «структура аналогична Task 3.3-3.4» — это допустимая отсылка к ОБРАЗЦУ кода, но для атомарной задачи стоит дублировать. Помечаю как known shortcut — engineer должен следовать pattern из 3.3 и 3.4.
- В Task 4.2-4.6, 5.2, 7.2-7.6 — «аналогично» отсылки. Это компромисс между читаемостью и DRY.
### Type consistency
- `core.employee.id` (bigserial) — везде bigint. ✅
- `core.task.cache_status_type` — везде text с CHECK на 4 значения. ✅
- `core.deal.bitrix_id` (bigint) — везде bigint. ✅
- Имя `core.merge_employee()` — везде с подчёркиванием. ✅
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-05-13-mvp1-workload.md`. Two execution options:**
**1. Subagent-Driven (recommended)** — я диспетчирую свежего субагента на каждую задачу, ревью между задачами, быстрая итерация.
**2. Inline Execution** — выполняем задачи в текущей сессии через executing-plans, batch-выполнение с чекпойнтами для ревью.
**Какой подход?**