diff --git a/docs/superpowers/plans/2026-05-13-mvp1-workload.md b/docs/superpowers/plans/2026-05-13-mvp1-workload.md new file mode 100644 index 0000000..67f5761 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-mvp1-workload.md @@ -0,0 +1,1386 @@ +# 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 -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= + PG_DB=bit_flight_deck + PG_HOST=pipeline_postgres + PG_PORT=5432 + + # BIT.RA HTTP-сервисы + BITRA_BASE_URL=http:////hs/IntegrationAPI/v1 + BITRA_USER= + BITRA_PASSWORD= + + # EVA Desk + EVA_BASE_URL=https://firstbit.evateam.ru/api/ + EVA_ADMIN_TOKEN= + + # Bitrix24 + BITRIX_WEBHOOK_URL=https://vdst421.1cbit.ru/rest/91// + BITRIX_WEBHOOK_SECRET= + + # Metabase + METABASE_SITE_NAME="bit-flight-deck" + METABASE_PORT=3001 + + # NocoDB + NOCODB_PORT=8090 + NOCODB_ADMIN_EMAIL=roachesnokov@gmail.com + NOCODB_ADMIN_PASSWORD= + ``` + +- [ ] **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 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/ + + Конфиг 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: http:////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: "http:////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.5–3.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.2–4.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.`. +- Этот таймстамп подставлять в 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 >= `. Получаем список 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/` (token из `.env`). + +- [ ] **Step 2:** В настройках workflow добавить **Production URL** — `https://n8n.bigmadnekenny.ru/webhook/bitrix/`. + +- [ ] **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=` со всеми UF-полями. + +- [ ] **Step 6:** Postgres upsert в `raw_bitrix.deals`. + +- [ ] **Step 7:** Для пользователей: `user.get.json?ID=` + upsert в `raw_bitrix.users`. + +- [ ] **Step 8:** В Bitrix создать outbound webhooks (в админ-панели → Разработчикам → Исходящие вебхуки → создать), указав URL: `https://n8n.bigmadnekenny.ru/webhook/bitrix/`. События: `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.2–7.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.8–7.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.14–7.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-выполнение с чекпойнтами для ревью. + +**Какой подход?**