1387 lines
61 KiB
Markdown
1387 lines
61 KiB
Markdown
# 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.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.<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.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-выполнение с чекпойнтами для ревью.
|
||
|
||
**Какой подход?**
|