Files
portal/db/CHANGELOG_schema.md
T
Дмитрий 9d2e7270de feat(projects): Plan 5 Task 3 — store + StoreProjectRequest + ProjectService::create
- StoreProjectRequest: 3-way conditional validation (site domain regex, call 7\d{10}, sms senders required)
- ProjectService::create(): max_projects limit check via Tenant.limits JSONB + dispatch SyncSupplierProjectJob
- ProjectController: constructor DI + store() method returning 201
- SyncSupplierProjectJob: stub (Task 4 полная реализация)
- POST /api/projects route inside auth:sanctum+tenant group (name projects.store)
- Migration add_limits_to_tenants: JSONB DEFAULT '{}' per-tenant limits column
- Tenant model: limits added to fillable + casts as array
- schema.sql/CHANGELOG: tenants.limits documented in v8.20
- phpstan-baseline: +8 actingAs entries for new test file
- Quirk: region_mode in request uses 'include'/'exclude' (schema CHECK) not 'all'/'whitelist' (plan spec typo)
- Quirk: Project::first() → Project::where('signal_identifier','x.ru')->latest()->first() (no RefreshDatabase, persistent test DB)
- 8/8 ProjectsStoreTest passed; 699/706 total (4 pre-existing failures unchanged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:29:54 +03:00

1274 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит девятнадцать записей в обратном хронологическом порядке (v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.20, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.20 (11.05.2026 — Plan 5)
**Added:**
- `projects.archived_at TIMESTAMPTZ NULL` — для soft archive flow (отличие от `is_active=false` который = pause). **Migration:** `app/database/migrations/2026_05_11_140000_add_archived_at_to_projects.php`
- `tenants.limits JSONB NOT NULL DEFAULT '{}'` — per-tenant override лимитов тарифа; используется `ProjectService::create()` для проверки `max_projects`. **Migration:** `app/database/migrations/2026_05_11_150000_add_limits_to_tenants.php`
## v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin
**Изменения:**
- `tenants` + колонка `delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK >= 0` (per-tenant счётчик для tier-lookup).
- `lead_charges` + колонка `charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')` + CHECK `chk_lead_charges_prepaid_zero_price` (prepaid → price=0).
- `supplier_leads` + колонка `recovered_from_csv_at TIMESTAMPTZ` + partial index.
- Новая таблица `supplier_csv_reconcile_log` (SaaS-level, без RLS) + 2 индекса.
- 0 RLS-политик изменено.
**Метрики:** 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS-политик (без изменений).
**Spec:** [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](../docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md).
- **v8.18 (10.05.2026)** — Plan 2/5 Task 1: подготовка слоя данных для supplier-webhook + sharing routing (spec §5–§6). Новая таблица `supplier_leads` (SaaS-level, без RLS) — raw-payload входящих webhook'ов от поставщика, FK на `supplier_projects(id) ON DELETE SET NULL`, 3 CHECK (platform enum / source enum / deals_count nonneg), 3 индекса (idx_received_at DESC + idx_supplier_project partial + UNIQUE на vid для idempotency). Новая колонка `projects.delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (>=0)` — дневной счётчик для проверки квоты, сбрасывается cron'ом в 00:00 МСК. 2 строки в `system_settings`: `supplier_webhook_secret` (string, placeholder `__SET_ON_DEPLOY__`) — platform-wide секрет в URL; `supplier_ip_allowlist` (json, default `[]`) — IP/CIDR поставщика. REVOKE: `supplier_leads` defense-in-depth (закомментирован, conditional wrapper аналогично `supplier_projects`). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §5–§6. Метрики: 60 → **61 базовая таблица** (+1) / 111 → **114 индексов** (+3) / **39 RLS-политик** (без изменений — supplier_leads SaaS-level) / функции/триггеры без изменений.
- **v8.17 (10.05.2026 поздний вечер)** — Plan 1/5 Task 2 fix (закрытие code-review BLOCKER#1 + WARNING#3): добавлены 3 FK constraints `projects.supplier_b{1,2,3}_project_id → supplier_projects(id) ON DELETE SET NULL` (заведены в v8.12 как placeholder BIGINT — FK был обещан в комментарии, но не добавлен в Task 2 commit). +3 partial индекса (idx_projects_supplier_b{1,2,3}_project_id WHERE NOT NULL) для FK lookup performance. +1 CHECK `chk_projects_b1_not_for_sms` (defense-in-depth: дублирует chk_supplier_projects_b1_not_for_sms на Project-уровне — `signal_type <> 'sms' OR supplier_b1_project_id IS NULL`). Метрики: 60 базовых таблиц (без изменений) / 111 индексов (+3) / 39 RLS-политик (без изменений) / функции/триггеры без изменений.
- **v8.16 (10.05.2026)** — Plan 1/5 Task 5: создание `supplier_sync_log` SaaS-level append-only audit log для AJAX-синхронизаций с поставщиком crm.bp-gr.ru. Колонки: id, supplier_project_id (nullable BIGINT, FK на supplier_projects ON DELETE SET NULL — лог переживает удаление supplier-проекта для audit-trail), action (VARCHAR(32)), request_payload (jsonb), response_body (jsonb), http_status (smallint), error_message (text), duration_ms (uint), created_at. 1 CHECK (`chk_supplier_sync_log_action` — action IN create/update/delete/disable/session_refresh). 3 индекса: btree на supplier_project_id (drill-down по проекту), btree на action (фильтрация по типу события), btree на created_at (timeline-запросы для алертов). **НЕ tenant-scoped** — события агрегатные на уровне SaaS. REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check). Используется для retry-логики, отладки rt-project-* AJAX и алертов менеджеру при failed sync. Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §4.3. Метрики: 60 базовых таблиц (+1) / 108 индексов (+3) / RLS/функции/триггеры без изменений.
- **v8.15 (10.05.2026)** — Plan 1/5 Task 4: создание `lead_charges` append-only ledger списаний за каждый доставленный лид. Колонки: id, tenant_id, deal_id, deal_received_at, tier_no (smallint), price_per_lead_kopecks (uint), charged_at, created_at. Composite FK `lead_charges_deals_fk(deal_id, deal_received_at) → deals(id, received_at) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED` — DEFERRABLE обязательно для атомарного INSERT deal+charge в одной транзакции (deals партиционирована, обычный FK не работает на партиционированную с composite ключом). FK на `tenants(id) ON DELETE CASCADE`. 2 индекса: btree (tenant_id, charged_at) для отчётов клиенту, btree (deal_id, deal_received_at) для drill-down по сделке. **Tenant-scoped** — RLS `tenant_isolation` ENABLE+FORCE с USING+WITH CHECK на `current_setting('app.current_tenant_id')::bigint`. Append-only гарантия для биллинга/аудита: GRANT SELECT, INSERT (без UPDATE/DELETE) для tenant-приложения через crm_app_user (миграция оборачивает в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §7.4. Метрики: 59 базовых таблиц (+1) / 105 индексов (+2) / 39 RLS-политик (+1) / функции/триггеры без изменений.
- **v8.14 (10.05.2026)** — Plan 1/5 Task 3: создание `pricing_tiers` SaaS-level таблицы для конфигурации 7-ступенчатого объёмного тарифа (volume billing). Колонки: id, tier_no (smallint 1..7), leads_in_tier (uint nullable; NULL = «всё свыше» для последней ступени), price_per_lead_kopecks (uint, копейки integer — избегаем floating-point округлений в money-расчётах; 1 руб = 100 коп.), is_active (default true), effective_from (date), timestamps. 1 CHECK constraint (`chk_pricing_tiers_tier_no``tier_no BETWEEN 1 AND 7`). 2 индекса: UNIQUE на (tier_no, effective_from), btree на (is_active, effective_from). **НЕ tenant-scoped** — конфигурация админом Лидерры; RLS НЕ применяется. Per-tenant override out of scope для MVP (один тариф на всю Лидерру). SELECT-only для tenant-приложения: REVOKE ALL FROM crm_app_user + GRANT SELECT TO crm_app_user (миграция оборачивает оба в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §7.2. Метрики: 58 базовых таблиц (+1) / 103 индекса (+2) / RLS/функции/триггеры без изменений.
- **v8.13 (10.05.2026)** — Plan 1/5 Task 2: создание `supplier_projects` SaaS-level агрегатной таблицы для проектов у поставщиков B1/B2/B3. Колонки: id, platform (B1/B2/B3), signal_type (site/call/sms), unique_key (TEXT — domain/phone/sender+keyword/sender), supplier_external_id, current_limit (uint, default 0), current_workdays (jsonb), current_regions (jsonb), sync_status (pending/ok/failed), last_synced_at, inactive_since, timestamps. 4 CHECK constraints (`chk_supplier_projects_platform`, `chk_supplier_projects_signal_type`, `chk_supplier_projects_sync_status`, `chk_supplier_projects_b1_not_for_sms` — B1 не поддерживает СМС). 3 индекса: UNIQUE на (platform, unique_key), btree на sync_status, btree на inactive_since. **НЕ tenant-scoped** — sharing-model между Лидерра-tenant'ами; RLS НЕ применяется. Defense-in-depth: REVOKE ALL FROM crm_app_user (миграция оборачивает в DO $$ EXISTS-check для совместимости с dev без роли). Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §2.2. Метрики: 57 базовых таблиц (+1) / 101 индекс (+3) / RLS/функции/триггеры без изменений.
- **v8.12 (10.05.2026)** — Plan 1/5 Task 1: расширение `projects` для supplier integration. +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable BIGINT placeholder, FK добавятся в Task 2 после создания supplier_projects). 3 CHECK constraints (signal_type enum; sms_senders required for sms; signal_identifier required for site/call) + 1 composite index `idx_projects_tenant_signal(tenant_id, signal_type, signal_identifier)`. Spec: `docs/superpowers/specs/2026-05-10-supplier-integration-design.md` §2.1.
- **v8.11 (09.05.2026)** — hygiene-фиксы аудита 2026-05-09: P0-02 RLS на `impersonation_tokens` + O-perf-02/03 индексы FK-колонок `webhook_log_id` на `failed_webhook_jobs` и `rejected_deals_log`. См. ниже §S.
- **v8.10 (09.05.2026)** — `in_app_notifications` таблица для bell-icon UI (P0 этап 2): event/title/body/payload/read_at + RLS tenant isolation + 2 индекса (unread + recent). См. ниже §T.
- **v8.9 (09.05.2026)** — bulk soft-delete для UI applyBulkDelete: `deals.deleted_at TIMESTAMPTZ` (NULL = живая сделка) + partial index `(tenant_id, status) WHERE deleted_at IS NULL`. См. ниже §U.
- **v8.8 (09.05.2026)** — `users.totp_secret` тип `VARCHAR(255)``TEXT`. Encrypted 32-байт TOTP secret после `Crypt::encryptString` = 256 chars (>255), `php artisan tinker` показал runtime PDOException на confirm wizard. См. ниже §V.
- **v8.7 (08.05.2026 поздний вечер)** — CTO-17 addendum: FK `webhook_dedup_keys → deals` стал `DEFERRABLE INITIALLY DEFERRED`. См. ниже §W.
- **v8.6 (08.05.2026 поздний вечер)** — CTO-17: `webhook_dedup_keys` взамен UNIQUE на партиционированной `deals`. См. ниже §X.
- **v8.5 (07.05.2026)** — реализация 27 решений аудита C (Открытые_вопросы v1.12). См. ниже §Y.
- **v8.4 (06.05.2026)** — синхронизация с narrative §19.10 (outbound webhook). См. ниже §Z.
- **v8.3 (05.05.2026)** — после параллельного аудита crm.bp-gr.ru, партии 1215. См. ниже §A.
- **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B.
**Связано:**
- `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.13.3).
- `Открытые_вопросы_v8_3.md` v1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.
- `README_АРХИВ_v8_4.md` — состав архива.
- `CRM_bp-gr_Инструкция_v8_4.md` v8.4 §19.10 — outbound webhook (источник изменений v8.4, финал 06.05.2026).
- `CRM_bp-gr_Инструкция_v8_5.md` (готовится) — narrative-обоснование v8.5 для §10/§12.5.5/§14/§17/§19.10/§22/§23.10/Прил.И.
**Замечание о нумерации:** внутри каждой записи разделы пронумерованы с префиксом записи (`Y.0`, `Y.1`, …, `Z.0`, `Z.1`, …, `A.0`, `A.1`, …, `B.0`, `B.1`, …) для устранения коллизий при кросс-ссылках. Изначальная нумерация `## 0`, `## 1` исходных CHANGELOG-файлов сохранена в виде второй части ID (после префикса).
---
# Запись S — v8.10 → v8.11 (09.05.2026) — hygiene-фиксы аудита
**Источник:** [docs/audit_2026-05-09.md](../docs/audit_2026-05-09.md) (commit `b6ae8dd`).
## S.1. Изменения
1. **P0-02:** Добавлены `ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY` и `CREATE POLICY tenant_isolation ON impersonation_tokens` (схема ~строка 540).
2. **O-perf-02:** Добавлен индекс `idx_failed_webhook_jobs_log` на `failed_webhook_jobs(webhook_log_id)`.
3. **O-perf-03:** Добавлен индекс `idx_rejected_deals_log_webhook` на `rejected_deals_log(webhook_log_id)`.
## S.2. Метрики (после v8.11)
- 56 базовых таблиц + 12 партиций (без изменений, 68 CREATE TABLE)
- **97 индексов** (было 95, +2)
- **38 RLS-политик** (было 37, +1 = `tenant_isolation` на `impersonation_tokens`)
- 5 функций, 13 триггеров (без изменений)
## S.3. Применение
Для нового стенда: `cd app && php artisan migrate:fresh`. Для существующих данных — `ALTER TABLE` + `CREATE POLICY` + `CREATE INDEX CONCURRENTLY` тремя раздельными DDL.
---
# Запись T — v8.9 → v8.10 (09.05.2026)
**Источник изменений:** этап 2 (этап 2a) плана P0 «Notification delivery». Email-канал реализован в v1.65 (этап 1), но in-app канал (bell-icon в `AppLayout`) требует persistence: при триггере события (new_lead/reminder/...) запись в БД, чтобы UI мог:
1. показывать unread-счётчик у иконки колокольчика (даже если user'а нет в момент события);
2. накапливать историю «10 последних» при заходе на страницу;
3. сохранять прочитанные/непрочитанные между сессиями.
**Что изменилось:**
1. Новая таблица `in_app_notifications` (после `reminders` в schema, оба про работу/коммуникации):
```sql
CREATE TABLE in_app_notifications (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event VARCHAR(50) NOT NULL, -- new_lead|reminder|...
title VARCHAR(255) NOT NULL,
body TEXT,
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
payload JSONB DEFAULT '{}'::jsonb, -- доп. поля для UI
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
2. Индекс `idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL` — основной UI-флоу «непрочитанные user'а».
3. Индекс `idx_in_app_notifications_user_recent (user_id, created_at DESC)` — «последние 50» (с прочитанными).
4. RLS `tenant_isolation` на `in_app_notifications` (стандартная политика по `current_setting('app.current_tenant_id')`).
**Почему отдельная таблица, а не Laravel `notifications`:**
- Laravel default `notifications` — generic morphable, не tenant-scoped, нет нашей RLS-обёртки;
- наша event-матрица фиксирована (8 событий из `users.notification_preferences`), generic-морфы избыточны;
- удобнее JOIN'ить по `deal_id` для UI-link (deep-link на DealDetailDrawer).
**Backend changes (отдельный коммит):**
- `App\Models\InAppNotification` — Eloquent с `payload` cast `array`, `read_at` cast `datetime`.
- `NotificationService::notifyInApp(User $user, string $event, array $opts)` — INSERT row с применением пользовательских prefs (`notification_preferences[event].inapp=true`).
- `notifyNewLead` теперь шлёт ДВА канала: email (если prefs.email=true) И in-app (если prefs.inapp=true). По schema-default `new_lead.inapp=true` — большинство получит in-app, и только подписавшиеся — email.
- INSERT в `in_app_notifications` обёрнут в транзакцию с `SET LOCAL app.current_tenant_id` для RLS-WITH CHECK ([USING/WITH CHECK симметричны без явного WITH CHECK](https://www.postgresql.org/docs/16/sql-createpolicy.html)).
**Frontend changes (этап 2b, отдельный коммит):**
- API endpoints (GET /api/notifications + PATCH /api/notifications/{id}/read + PATCH /mark-all-read).
- Pinia store `useNotificationsStore` с polling 30 сек для unread-count.
- Bell-icon в `AppLayout.topbar` с pip + `v-menu` для последних 10.
**Миграция production-БД:**
```sql
CREATE TABLE in_app_notifications (...); -- см. выше
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_unread ...;
CREATE INDEX CONCURRENTLY idx_in_app_notifications_user_recent ...;
ALTER TABLE in_app_notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON in_app_notifications USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
```
DOWN: `DROP TABLE in_app_notifications` (необратимо без архива истории, на MVP допустимо).
**Метрики после v8.10:** 56 таблиц + 12 партиций + **95 индексов** (+2 от 93) + **37 RLS** (+1 от 36) + 5 функций + 13 триггеров.
---
# Запись U — v8.8 → v8.9 (09.05.2026)
**Источник изменений:** этап 5/5 авто-плана — backend-persistence для UI-операции applyBulkDelete в DealsView. До этого изменения bulk-delete выполнялся только локально (mutation `dealsState.splice` без API-вызова), при reload-btn удалённые сделки возвращались.
**Что изменилось:**
1. `deals.deleted_at TIMESTAMPTZ` — новая колонка. NULL = живая сделка, `not null` = soft-deleted (момент удаления).
2. `CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL` — partial index по самому частому UI-фильтру (DealsView::index скрывает удалённые).
**Почему soft-delete (не hard):**
- Партиционированная `deals` имеет CASCADE-FK от `webhook_dedup_keys` (через composite-FK `(deal_id, deal_received_at)`). Hard-delete пройдёт CASCADE и удалит dedup-keys → следующий webhook с тем же `vid` создаст дубль (нарушение идемпотентности §5.5).
- Soft-delete сохраняет dedup-keys и позволяет restore-flow (отдельный endpoint POST /api/deals/{id}/restore — отдельный коммит).
- `applyBulkDelete` в UI: «Удалить N сделок» с двойным подтверждением. На production — soft-delete + email-уведомление tenant'у.
- UX-pattern: на API-fail локальный update НЕ откатывается (как у applyBulkStatus в v1.52) — пользователь видит что хотел, перезагрузит позже.
**Backend changes (DealController):**
- `index` / `show` / `transition` / `update` / `export` все добавили `whereNull('deleted_at')` фильтр. Soft-deleted скрыты от регулярных flow.
- `destroy` — новый endpoint `DELETE /api/deals?tenant_id=X&ids[]=...`: bulk-update `deleted_at=NOW()` через RLS + defense-in-depth `where(tenant_id)`. Запись `ActivityLog event=deal.deleted` для каждой удалённой сделки.
**Frontend changes:**
- `dealsApi.bulkDeleteDeals(payload)` — DELETE helper.
- `DealsView::applyBulkDelete` async: optimistic local-removal + backend-вызов если auth.user; на success — toast «Удалено N»; на fail — warning toast (без auto-rollback — UX-pattern как в bulk-status).
**Миграция production-БД:**
```sql
ALTER TABLE deals ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX CONCURRENTLY ON deals (tenant_id, status) WHERE deleted_at IS NULL;
```
ALTER TABLE на партиционированной `deals` distributes колонку во все партиции автоматически (PG 14+). CONCURRENTLY для index — без блокировки production-таблицы.
---
# Запись V — v8.7 → v8.8 (09.05.2026)
**Источник изменений:** реализация 2FA setup wizard (`AuthController::useRecoveryCode` + `TwoFactorSetupController::confirm`) поймал PDOException `String data, right truncated: 7 ... character varying(255)` при сохранении encrypted TOTP secret.
**Корневая причина:**
- `Google2FA::generateSecretKey()` возвращает 32-символьный base32-secret.
- Eloquent cast `'encrypted'` через `Crypt::encryptString` упаковывает в JSON `{iv,value,mac,tag}` base64 → длина около **256 chars** для 32-байт исходника.
- Schema v8.7 имеет `users.totp_secret VARCHAR(255)` — не вмещается.
**Изменение:**
```sql
-- До
totp_secret VARCHAR(255), -- ШИФРУЕТСЯ Crypt::encrypt
-- После
totp_secret TEXT, -- ШИФРУЕТСЯ Crypt::encrypt (encrypted ~256 chars > VARCHAR(255))
```
`saas_admin_users.totp_secret_enc` уже был `TEXT` в v8.5 (§22.4.2 / Прил. Г.4.2 — encrypted) — теперь users.totp_secret приведена к тому же паттерну.
**Деплой:**
```sql
ALTER TABLE users ALTER COLUMN totp_secret TYPE TEXT;
```
Данных в production пока нет (фаза 2 на dev), миграция безболезненная. На dev прогнан `php artisan migrate:fresh` для liderra и liderra_testing.
**Связано:**
- `CLAUDE.md` v1.40 — schema v8.7 → v8.8 в §0/§2.
- `app/app/Models/User.php` — добавлен cast `'totp_secret' => 'encrypted'`.
- `app/app/Http/Controllers/Api/TwoFactorSetupController.php` — новый wizard.
---
# Запись W — v8.6 → v8.7 (08.05.2026 поздний вечер)
**Источник изменений:**
- **CTO-17 addendum (фаза 1, Webhook PoC)** — при имплементации `App\Jobs\ProcessWebhookJob` по спецификации narrative §5.5 v8.6 Pest-тест поймал FK violation:
- `SQLSTATE[23503]: Foreign key violation … webhook_dedup_keys_deal_id_deal_received_at_fkey … Ключ (deal_id, deal_received_at)=(N, …) отсутствует в таблице "deals".`
- §5.5 v8.6 спецификация: INSERT в `webhook_dedup_keys` через `nextval('deals_id_seq')` ДО INSERT в `deals`. Без `DEFERRABLE` FK проверяется immediate — нарушается до момента INSERT в `deals`.
**Эволюция решения (две стадии):**
1. **Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK** (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT.
2. **Стадия 2 — pivot на advisory lock** (что попало в production-код). При запуске Pest с `DatabaseTransactions` trait DEFERRED FK всё равно падает: PG проверяет deferred constraints на **RELEASE SAVEPOINT** (внутренняя `DB::transaction()` Job'а становится savepoint при наличии outer-txn от теста), не на outer COMMIT. Это PG-семантика subtransactions, не Laravel-bug. Воспроизводится stand-alone PHP-скриптом.
**Финальный архитектурный паттерн (v8.7 production code):**
`App\Jobs\ProcessWebhookJob::upsertDeal()`:
```php
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
$existing = DB::selectOne(
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
[$tenant->id, $sourceCrmId],
);
if ($existing !== null) {
// UPDATE deal по composite-ключу
} else {
// INSERT deal первым (FK immediate OK), затем INSERT dedup_key
}
```
**Альтернативы отброшены:**
- Reverse INSERT order (deals → dedup_keys) без advisory lock — race condition между concurrent webhook'ами с одинаковым `vid`: оба INSERT'ят deal, второй получает unique violation на dedup_key и оставляет orphan deal. Требует cleanup-логики, может упасть при retry.
- SELECT FOR UPDATE на dedup_keys — race condition (FOR UPDATE на несуществующей строке не блокирует).
- Advisory lock покрывает оба сценария: serialization для одинакового vid, lock авто-освобождается на COMMIT/ROLLBACK.
**Schema-изменение (v8.7):**
```sql
CREATE TABLE webhook_dedup_keys (
...
FOREIGN KEY (deal_id, deal_received_at) REFERENCES deals (id, received_at)
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED -- v8.7
);
```
`DEFERRABLE` сохранён как **defense-in-depth** — позволяет альтернативные паттерны записи в production-коде без savepoints (например, batch-импорт CSV в одной транзакции). `ON DELETE CASCADE` по-прежнему срабатывает immediate на DELETE строки в `deals`.
**Импакт:**
- `db/schema.sql:1315-1325` — единственная DDL-правка (`DEFERRABLE INITIALLY DEFERRED` + блок-комментарий).
- Метрики schema без изменений: 55 таблиц + 12 партиций, 92 индекса, 36 RLS-политик, 36 ENABLE RLS, 5 функций, 13 триггеров.
- Narrative ТЗ §11 (DDL `webhook_dedup_keys`) обновлён — добавлен `DEFERRABLE` + объяснение defense-in-depth.
- Narrative ТЗ §5.5 (PHP-код) обновлён — переписан на advisory lock + INSERT-deal-первым.
- Narrative ТЗ §6.5 (CSV-импорт) и §2.4 (поток) обновлены — упоминают advisory lock.
**Проверка (08.05.2026 поздний вечер):**
- `php artisan migrate:fresh --env=testing` на `liderra_testing` БД — прошёл.
- Pest **31/31** на полном test suite (DealModelTest 6 + ProcessWebhookJobTest 6 + RlsSmokeTest 4 + TenantModelsTest 8 + SetTenantContextTest 5 + ExampleTest 2) — все зелёные.
- Всё backward-compat: dev-БД `liderra` пересоздана через тот же `migrate:fresh`.
**Связано:**
- `Открытые_вопросы_v8_3.md` v1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер).
- `CRM_bp-gr_Инструкция_v8_5.md §11` (in-place hygiene) — DDL `webhook_dedup_keys` синхронизирован с DEFERRABLE.
- `CLAUDE.md` v1.12 — schema v8.6 → v8.7 в §0.
---
# Запись X — v8.5 → v8.6 (08.05.2026 поздний вечер)
**Источник изменений:**
- **CTO-17 (фаза 1, реальный запуск миграции)** — переоткрытие schema.sql v8.5 после первой попытки `php artisan migrate:fresh` на dev-БД `liderra`. PostgreSQL 16 отклонил DDL `CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL` (`schema.sql:1263` v8.5):
- `SQLSTATE[0A000]: Feature not supported: 7 ОШИБКА: ограничение уникальности в секционированной таблице должно включать все секционирующие столбцы. DETAIL: В ограничении UNIQUE таблицы "deals" не хватает столбца "received_at", входящего в ключ секционирования.`
- PostgreSQL требует, чтобы UNIQUE на партиционированной таблице включал все partition key columns. `deals` партиционируется по `received_at`.
- Включить `received_at` в UNIQUE нельзя — ломает идемпотентность webhook'ов от crm.bp-gr.ru. Тот же `vid` при retry с разным timestamp создаст дубль вместо UPDATE существующей сделки. Это противоречит ТЗ §15 («Используется для идемпотентности») и §16 (`ON CONFLICT (tenant_id, source_crm_id) DO UPDATE`).
**Решение (08.05.2026, заказчик: «архитектурный фикс»):**
Идемпотентность вынесена в отдельную не-партиционированную таблицу `webhook_dedup_keys`:
| Поле | Тип | Назначение |
|---|---|---|
| `tenant_id` | `BIGINT NOT NULL` | + `source_crm_id` = идемпотентный ключ webhook'а |
| `source_crm_id` | `BIGINT NOT NULL` | `vid` из webhook crm.bp-gr.ru |
| `deal_id` | `BIGINT NOT NULL` | ссылка на сделку |
| `deal_received_at` | `TIMESTAMPTZ NOT NULL` | partition key для FK на партиционированную `deals` |
| `created_at`, `updated_at` | `TIMESTAMPTZ` | стандартный аудит |
- **PRIMARY KEY**: `(tenant_id, source_crm_id)` — обеспечивает глобальную идемпотентность независимо от партиционирования `deals`.
- **FOREIGN KEY**: `(deal_id, deal_received_at) REFERENCES deals (id, received_at) ON DELETE CASCADE` — composite FK на partitioned-таблицу, корректно работает на PG 16.
- **INDEX**: `idx_webhook_dedup_keys_deal (deal_id, deal_received_at)` для обратного lookup'а из deal к dedup-ключу.
- **RLS**: tenant_isolation USING + WITH CHECK по `tenant_id = current_setting('app.current_tenant_id')::bigint`. Defense-in-depth поверх FK.
**Изменение в `deals`:**
- `CREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL` → `CREATE INDEX` (без UNIQUE). Индекс остаётся для скорости lookup'а master-сделок (Биз-19 dedup query 24-часового окна).
**Изменение webhook handler logic (narrative ТЗ §15-16):**
- **Было:** `INSERT INTO deals ... ON CONFLICT (tenant_id, source_crm_id) DO UPDATE` (одна транзакция, одна вставка).
- **Стало:** двустадийная операция в одной транзакции:
1. `INSERT INTO webhook_dedup_keys (tenant_id, source_crm_id, deal_id, deal_received_at) VALUES (...) ON CONFLICT (tenant_id, source_crm_id) DO UPDATE SET deal_received_at = EXCLUDED.deal_received_at, updated_at = NOW() RETURNING (xmax = 0) AS is_new, deal_id, deal_received_at;`
2. Если `is_new = true` → `INSERT INTO deals ...` с pre-allocated `deal_id` (через `nextval('deals_id_seq')`); иначе `UPDATE deals SET ... WHERE id = :deal_id AND received_at = :deal_received_at`.
- **Trade-off:** +1 запрос на webhook. На MVP-нагрузке (≤10 RPS на тенанта) незначимо. На высоких нагрузках можно оптимизировать через CTE-комбо.
**Метрики schema.sql:**
| Метрика | v8.5 | v8.6 | Δ |
|---|---|---|---|
| Таблицы | 54 | 55 | +1 (`webhook_dedup_keys`) |
| Партиции | 12 | 12 | — |
| Индексы | 91 | 92 | +1 (`idx_webhook_dedup_keys_deal`) |
| RLS-политики | 35 | 36 | +1 (`tenant_isolation ON webhook_dedup_keys`) |
| ENABLE RLS | 35 | 36 | +1 |
| Роли БД | 4 | 4 | — |
| Триггеры | 12 | 12 | — |
| Функции | 4 | 4 | — |
**Затронутые файлы:**
- `db/schema.sql`: заголовок v8.5→v8.6, строки ~1263 (CREATE INDEX без UNIQUE), новый блок `webhook_dedup_keys` после партиций deals (~1283), `ALTER TABLE webhook_dedup_keys ENABLE RLS` (§12), `CREATE POLICY tenant_isolation ON webhook_dedup_keys` (§12).
- `db/CHANGELOG_schema.md`: запись X (этот блок).
- `docs/CRM_bp-gr_Инструкция_v8_5.md`: §15 + §16 — обновить SQL-примеры с `ON CONFLICT (tenant_id, source_crm_id) DO UPDATE` на двустадийную логику. **Техдолг следующих сессий** (см. реестр Открытых вопросов CTO-17).
- `docs/Открытые_вопросы_v8_3.md`: добавить запись CTO-17 (закрыт фиксом, но техдолг по narrative).
**Совместимость:**
- БД v8.5 → v8.6: для пустой БД (dev) — `migrate:fresh`. Для production-БД с данными миграция не применима (но v8.5 на production ещё не разворачивался — это первая live-проверка). На v8.5-dev записей deals 0 — нет потери данных.
---
# Запись Y — v8.4 → v8.5 (07.05.2026)
**Источник изменений:**
- Закрытие 27 вопросов аудита C от 07.05.2026 (Открытые_вопросы v1.12, §13.10). Решение заказчика: «A везде» (рекомендованные варианты).
- 8 P0 разблокированы для триггера фазы 1 (`composer create-project laravel/laravel app`): Биз-17/18/19, CTO-13, OPEN-И-13/14/15/16.
- 12 P1 + 7 P2 — реализация в фазах 1–3.
## Y.0. Сводка
| Параметр | v8.4 | v8.5 | Δ |
|---|---|---|---|
| Логических таблиц | 53 | **54** | +1 (project_user_assignments) |
| Партиций | 12 | 12 | =0 |
| Индексов | 86 | **91** | +5 |
| RLS-политик | 34 | **35** (+ WITH CHECK на 2 существующих) | +1 (project_user_assignments) |
| Защищённых таблиц (ENABLE) | 34 | **35** | +1 |
| Ролей БД | 3 | **4** | +1 (crm_audit_writer) |
| Триггеров | 0 | **12** | +12 |
| Функций | 0 | **4** | +4 |
| Колонок (приращение) | — | **+26** | +26 (см. §Y.2) |
| Полей в `tenants` | 23 | 25 | +2 (api_key_limit, telegram_bot_token) |
## Y.1. Новые таблицы
### Y.1.1. `project_user_assignments` (CTO-16)
M2M-связь «проект ↔ менеджеры» с per-assignment skills (JSONB-массив). На MVP при `projects.assignment_strategy='manual'` (Биз-17 default) таблица не используется. После Post-MVP cron lead-router читает active members проекта и распределяет лидов согласно стратегии.
**Поля:** `project_id, user_id, skills JSONB, is_active, created_at, updated_at`. PK = `(project_id, user_id)`.
**Индекс:** `idx_project_user_assignments_user` partial WHERE `is_active=TRUE`.
**RLS:** `tenant_isolation` через JOIN на `projects.tenant_id` (USING + WITH CHECK).
## Y.2. Новые колонки
### Y.2.1. P0-блок (8)
- **`projects.assignment_strategy`** (Биз-17) `VARCHAR(32) NOT NULL DEFAULT 'manual'` + CHECK `IN ('manual','round_robin','least_loaded')`. MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.
- **`projects.ttfr_target_minutes`** (Биз-18) `INT NOT NULL DEFAULT 15` + CHECK `BETWEEN 1 AND 1440`. SLA по Time To First Response, alert при просрочке.
- **`deals.duplicate_of_id`** (Биз-19) `BIGINT` (без FK — партиционированная таблица). Окно дедупа 24 ч, проверяется приложением через индекс `(tenant_id, phone)`.
- **`deals.escalated_count`** (OPEN-И-25) `INT NOT NULL DEFAULT 0` + CHECK `>= 0`. Счётчик переназначений cron'ом `leads:escalate-stale`.
- **`deals.assigned_at`** (OPEN-И-25) `TIMESTAMPTZ`. Момент назначения менеджера.
- **`saas_admin_users.sso_provider`** (OPEN-И-13) `VARCHAR(32) NOT NULL DEFAULT 'yandex360'` + CHECK `IN ('yandex360','local')`.
- **`saas_admin_users.is_break_glass`** (OPEN-И-13) `BOOLEAN NOT NULL DEFAULT FALSE`. Аварийный аккаунт при недоступности IDP.
- **`auth_log.log_hash`, `activity_log.log_hash`, `pd_processing_log.log_hash`, `saas_admin_audit_log.log_hash`, `balance_transactions.log_hash`** (OPEN-И-15) `BYTEA` — заполняется триггером `audit_chain_hash()` (см. §Y.4.1).
### Y.2.2. P1-блок (12)
- **`tenants.api_key_limit`** (OPEN-И-19) `INT NOT NULL DEFAULT 5` + CHECK `BETWEEN 1 AND 10`.
- **`tenants.telegram_bot_token`** (Биз-20) `TEXT` (зашифровано Crypt::encryptString).
- **`users.telegram_user_id`** (Биз-20) `BIGINT` (Telegram chat_id).
- **`impersonation_tokens.second_approver_id`** (CTO-15 + Ю-9) `BIGINT REFERENCES saas_admin_users(id)`.
- **`impersonation_tokens.second_approval_at`** (CTO-15 + Ю-9) `TIMESTAMPTZ`.
- **`deals.utm_source/utm_medium/utm_campaign/utm_content`** (CTO-14) 4 × `VARCHAR(100)`.
- **`suppliers.quality_score`** (Биз-22) `NUMERIC(3,2) NOT NULL DEFAULT 1.00` + CHECK `BETWEEN 0.00 AND 9.99`.
- **`deals.time_in_form_seconds`** (Биз-22) `INT`. Сколько секунд физлицо заполняло форму.
- **`deals.lead_score`** (Биз-22) `NUMERIC(5,2)` (заполняется триггером `calc_lead_score()`, см. §Y.4.2).
### Y.2.3. P2-блок (7) — 2 новых колонки + остальные через DDL/триггеры
- **`deals.region_code`** (Биз-23) `VARCHAR(8)`. ISO 3166-2:RU; автоопределение по prefix phone в PhonePrefixService.
- **`deals.city`** (Биз-23) `VARCHAR(100)`. Свободный текст из webhook/enrichment.
(Остальные P2-решения реализованы через DDL-блоки: триггеры/функции в §Y.4, закомментированный задел `call_recordings` для Биз-12/OPEN-И-26 — в новой секции 17 schema.sql.)
### Y.2.4. ALTER (1)
- **`api_keys.expires_at`** (OPEN-И-17 + OPEN-И-19): из NULL-able без default → `NOT NULL DEFAULT NOW() + INTERVAL '365 days'`. Миграция: backfill всем существующим NULL-ключам `NOW() + 365d` перед применением `SET NOT NULL`.
## Y.3. Новые индексы (5)
- **`(tenant_id, utm_source) WHERE utm_source IS NOT NULL`** на `deals` (CTO-14). Для когортной аналитики §12.5.5.
- **`(tenant_id, region_code) WHERE region_code IS NOT NULL`** на `deals` (Биз-23). Для гео-фильтра в §10.3.
- **`(duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** на `deals` (Биз-19). Для UI-цепочки дублей и cleanup при удалении master'а.
- **`(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')`** на `deals` (OPEN-И-25). Для cron `leads:escalate-stale`.
- **`idx_project_user_assignments_user(user_id) WHERE is_active = TRUE`** (CTO-16). Для «список моих назначений».
Индекс `(tenant_id, phone, received_at)` для Биз-19 24ч-lookup НЕ создан — существующий `(tenant_id, phone)` справляется с фильтрацией приложением (24-часовое окно — мелкая выборка).
## Y.4. Новые функции и триггеры
### Y.4.1. `audit_chain_hash()` + `audit_block_mutation()` + 10 audit-триггеров (OPEN-И-15)
```sql
audit_chain_hash() RETURNS TRIGGER:
prev_hash := SELECT log_hash FROM TG_TABLE_NAME ORDER BY id DESC LIMIT 1;
NEW.log_hash := digest(COALESCE(prev_hash, '') || NEW::text::bytea, 'sha256');
RETURN NEW;
audit_block_mutation() RETURNS TRIGGER:
RAISE EXCEPTION 'audit log is append-only (table %): UPDATE/DELETE forbidden', TG_TABLE_NAME;
```
На каждой из 5 audit-таблиц по 2 триггера: `BEFORE INSERT` (hash chain) + `BEFORE UPDATE OR DELETE` (block mutation). Итого 10 триггеров.
**Юридический эффект:** любая модификация audit-журнала после INSERT'а технически запрещена. При попытке INSERT с поддельной строкой между существующими — пересчёт цепочки в cron `audit:verify-chain` обнаружит разрыв (sha256-mismatch).
**Защита от ALTER TABLE … DISABLE TRIGGER:** даже при отключении триггеров роль `crm_audit_writer` (см. §Y.5) имеет только INSERT — UPDATE/DELETE заблокированы на уровне permissions.
### Y.4.2. `calc_lead_score()` + триггер `trg_deals_calc_lead_score` (Биз-22)
```sql
calc_lead_score() RETURNS TRIGGER:
IF NEW.time_in_form_seconds IS NULL → lead_score := NULL; RETURN.
quality := SELECT s.quality_score FROM project_suppliers ps JOIN suppliers s
WHERE ps.project_id = NEW.project_id AND ps.is_active AND s.is_active
ORDER BY s.sort_order, s.id LIMIT 1;
NEW.lead_score := LEAST(quality * (time_in_form_seconds / 60.0), 99.99);
```
Триггер `BEFORE INSERT OR UPDATE OF time_in_form_seconds, project_id ON deals`. Использован триггер вместо `GENERATED ALWAYS AS … STORED` потому что PostgreSQL не разрешает в STORED-выражениях JOIN на foreign tables.
### Y.4.3. `report_jobs_log_export()` + триггер `trg_report_jobs_export_log` (OPEN-И-20)
`AFTER INSERT ON report_jobs → INSERT INTO pd_processing_log (action='exported', purpose='report_job_<id>')`. Закрывает риск пропуска audit-записи при экспорте лидов из app-кода.
## Y.5. Новая роль `crm_audit_writer` (OPEN-И-15 + OPEN-И-23)
```sql
CREATE ROLE crm_audit_writer LOGIN PASSWORD '<from-secrets>';
GRANT USAGE ON SCHEMA public;
GRANT INSERT ON auth_log, activity_log, pd_processing_log,
saas_admin_audit_log, balance_transactions;
GRANT USAGE ON sequences соответствующих таблиц.
-- запрещено: SELECT, UPDATE, DELETE, TRUNCATE.
```
Application пишет в audit-таблицы под этой ролью через temporary `SET ROLE crm_audit_writer`, что обеспечивает невозможность fraud-удаления записей даже от `super_admin` SaaS.
## Y.6. RLS WITH CHECK (OPEN-И-14)
Существующие политики `tenant_isolation` на двух таблицах обогащены `WITH CHECK`:
- **`saas_invoice_items`** — нельзя вставить invoice_item ссылающуюся на чужой invoice.
- **`deal_tag_pivot`** — нельзя пометить deal чужим тегом.
До v8.5 защита была только на USING (SELECT/UPDATE filter), INSERT мог пройти при наличии knowledge о `tag_id` чужого тенанта.
Новая политика `project_user_assignments` создана с обоими USING + WITH CHECK сразу.
## Y.7. REVOKE ALL на 6 saas-таблицах (OPEN-И-14)
Defense-in-depth: к этим таблицам tenant-приложение (роль `crm_app_user`) доступа НЕ должно иметь даже теоретически.
```sql
REVOKE ALL ON saas_admin_users FROM crm_app_user;
REVOKE ALL ON saas_admin_sessions FROM crm_app_user;
REVOKE ALL ON saas_admin_audit_log FROM crm_app_user;
REVOKE ALL ON incidents_log FROM crm_app_user;
REVOKE ALL ON pd_subject_requests FROM crm_app_user;
REVOKE ALL ON impersonation_tokens FROM crm_app_user;
```
## Y.8. Изменения, не отражённые в schema (только в narrative)
- **CTO-13** (e2e-тест `SET LOCAL` через PgBouncer transaction-pooling) — план в narrative §22 + Прил. И; без DDL.
- **OPEN-И-16** (Sentry whitelist + regex) — конфигурация Laravel `config/sentry.php` `before_send`; без DDL.
- **OPEN-И-18** (DNS-rebinding защита resolve→pin→connect) — реализация в `App\Services\Webhook\SSRFGuard`; без DDL.
- **OPEN-И-21** (Anti-DDoS: Nginx + Yandex SmartCaptcha + disposable-blacklist) — конфиг Nginx + Laravel middleware; без DDL.
- **OPEN-И-22** (per-tenant DEK Yandex KMS) — на уровне backup-сервиса (Прил. И); без DDL.
- **OPEN-И-24** (`pg_anonymizer` процедура) — Прил. И, расширение PG ставится в фазе 3 (Прил. Н).
- **Биз-20** Telegram-канал — спринт 9, реализация в фазе 2; DDL уже добавлен (telegram_user_id, telegram_bot_token), используется по факту с фазы 2.
- **Биз-21** generic outbound `marketing.conversion` — расширение whitelist событий в `App\Services\Outbound\EventTypes`; без DDL (хранится в `outbound_webhook_subscriptions.events`).
## Y.9. Что НЕ добавлено в v8.5 (отложено)
- Таблицы `crm_connections` / `crm_field_mappings` (Уровень 2 OPEN-И-2) — спринты 1415 (как и в v8.4).
- Структуры под Биз-12 (телефония + call recording) — `call_recordings` оставлена закомментированным заделом в секции 17 schema.sql; реальная активация Post-MVP при первом запросе клиента.
- Расширения PG `pg_partman`/`pgaudit`/`pg_anonymizer` — фаза 3 по Прил. Н.
## Y.10. Совместимость
- **Forward-only:** v8.5 разворачивается с нуля и не требует миграции с v8.4 (база ещё не в production — фаза 0).
- **Будущая прод-миграция** (после Б-1 → спринт 11+) — единственная транзакция `BEGIN; \i schema.sql; COMMIT;` от пустой базы до текущей версии.
- **Backfill для `api_keys.expires_at`** — при переходе с v8.4 на v8.5 на dev/staging выполнить `UPDATE api_keys SET expires_at = NOW() + INTERVAL '365 days' WHERE expires_at IS NULL;` ДО применения `ALTER ... SET NOT NULL`. На production это не требуется (база с нуля).
---
# Запись Z — v8.3 → v8.4 (06.05.2026)
**Источник изменений:**
- Переписывание narrative v8.3 → v8.4 06.05.2026, раздел §19.10 «Outbound webhook».
- Решение OPEN-И-2 (закрыто 04.05.2026): Уровень 1 стратегии CRM-интеграций — outbound webhook на MVP.
- Тех-долг шапки narrative v8.4: «при правке §7 добавить DDL `outbound_webhook_subscriptions` и `outbound_webhook_deliveries`».
## Z.0. Сводка
| Параметр | v8.3 | v8.4 | Δ |
|---|---|---|---|
| Логических таблиц | 51 | **53** | +2 |
| Партиций | 12 | 12 | =0 |
| Индексов | 81 | **86** | +5 |
| RLS-политик | 31 | **33** | +2 |
| Защищённых таблиц (ENABLE) | 32 | **34** | +2 |
| Полей в `tenants` | 23 | 23 | =0 |
## Z.1. Новые таблицы
### Z.1.1. `outbound_webhook_subscriptions`
Регистрация подписок тенантов на исходящие события сделок. Hash secret + key_prefix аналогично `api_keys` (раздел 19.3 narrative). Список событий — JSONB-массив с whitelist на стороне приложения. Не более 10 активных подписок на тенанта (проверка в Application layer; SQL-слой обеспечивает только базовый CHECK на структуру `events`).
**Поля:** `id, tenant_id, user_id, name, target_url, secret_hash, secret_prefix, events JSONB, custom_headers JSONB, is_active, paused_at, last_delivery_at, last_failure_at, consecutive_failures, created_at, updated_at`.
**Индексы:** `idx_outbound_subs_tenant_active` (partial WHERE is_active), `idx_outbound_subs_secret_prefix`.
### Z.1.2. `outbound_webhook_deliveries`
Журнал попыток доставки. Retention 90 дней (как `webhook_log`). Status-флоу: `pending → success | failed → permanently_failed` после 7 попыток. Retry с возрастающим интервалом (30 сек / 5 мин / 30 мин / 2 ч / 6 ч / 24 ч — см. narrative §19.10.6).
**Поля:** `id, tenant_id, subscription_id, delivery_uuid, event, payload JSONB, attempt_number SMALLINT, status, http_status_code, response_body, response_time_ms, error_message, scheduled_at, started_at, finished_at, next_retry_at, created_at`.
**Индексы:** `idx_outbound_deliveries_subscription` (по подписке), `idx_outbound_deliveries_status_pending` (partial для воркера retry), `idx_outbound_deliveries_created`.
## Z.2. RLS-политики
Обе таблицы получили `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY tenant_isolation` по `tenant_id` — стандартный паттерн tenant-таблиц (как `api_keys`, `webhook_log`).
## Z.3. Что НЕ добавлено в v8.4
- Таблицы `crm_connections` / `crm_field_mappings` (упомянуты в плане v8.4 для §7) — отложены до **спринта 14–15** (старт реализации Уровня 2 — нативный коннектор amoCRM, OPEN-И-2). DDL появится в schema v8.5+ при подготовке этих спринтов. До этого момента outbound webhook Уровня 1 (этой записи) — единственный канал интеграции с внешними CRM, и он работает без `crm_connections`.
## Z.4. Совместимость
- **Forward-only:** v8.4 разворачивается с нуля и не требует миграции с v8.3 (база ещё не в production — фаза 0).
- **При первом деплое в production** (спринт 11 после Б-1) — миграция от пустой базы до v8.4 одной транзакцией.
## Z.5. Hotfix 06.05.2026 — P0-блокеры миграции
По итогам аудита B (06.05.2026) в `schema.sql` v8.4 найдены P0-проблемы, из-за которых `psql -f schema.sql` падал бы на пустой базе. Это правки внутри той же версии v8.4 (метрики 53/86/33/34 не меняются — только перенос FK и снятие битых `WHERE`-предикатов с partial-индексов).
### Z.5.1. Forward-FK в SaaS-блоке → ALTER TABLE после CREATE TABLE tenants
`saas_admin_sessions` (Ю-1, импersonation) и `impersonation_tokens` объявлены **выше** по тексту, чем `CREATE TABLE tenants` (раздел 3 narrative — SaaS-админка идёт перед tenant-данными). Inline-FK `REFERENCES tenants(id)` внутри их `CREATE TABLE` падает на forward-reference при разворачивании с нуля.
**Решение:** в обоих `CREATE TABLE` поля оставлены типа `BIGINT` без inline-FK; ниже, сразу после `CREATE INDEX`-блока tenants, добавлены два `ALTER TABLE ... ADD CONSTRAINT FOREIGN KEY ... REFERENCES tenants(id)` (с `ON DELETE CASCADE` для `impersonation_tokens.tenant_id`). Аналогично уже было сделано для `saas_admin_sessions.impersonating_token_id` → `impersonation_tokens(id)` в исходной v8.4.
### Z.5.2. Partial index по `expires_at` с предикатом `WHERE expires_at > NOW()`
Два индекса (`idx_saas_admin_sessions_expires`, `idx_sessions_expires`) использовали `WHERE expires_at > NOW()`. PostgreSQL запрещает в предикате частичного индекса непостоянные функции (`NOW()` — STABLE, не IMMUTABLE) — `CREATE INDEX` падает.
**Решение:** оба индекса переведены на полное поле без `WHERE`. Поле `expires_at` объявлено `NOT NULL`, поэтому partial по `IS NOT NULL` бессмыслен. Индексы используются cron-очисткой expired-сессий — полный индекс корректен.
### Z.5.3. `outbound_webhook_subscriptions.events` — снят DEFAULT '[]'
`events JSONB NOT NULL DEFAULT '[]'` конфликтовал с `CHECK (jsonb_array_length(events) > 0)`: любой INSERT без явного `events` падал бы. Снят DEFAULT, остался `NOT NULL` — приложение обязано явно передать список событий ≥ 1 элемента.
### Z.5.4. `deal_tag_pivot` — добавлены ENABLE RLS + tenant_isolation
Связь `deals` ↔ `deal_tags`. У pivot нет `tenant_id`, у `deals` (партиционированной) RLS не работает в виде `WHERE tenant_id = ...` — используется RLS через JOIN на `deal_tags(tenant_id)`. Добавлен паттерн как у `saas_invoice_items` (`invoice_id IN (...)`).
### Z.5.5. Метрики после hotfix
`grep -c '^CREATE TABLE '` = **65** (53 логических + 12 партиций), `grep -c '^CREATE INDEX\|^CREATE UNIQUE INDEX'` = **86**, `grep -c '^ALTER TABLE.*ENABLE ROW LEVEL SECURITY'` = **34**, `grep -c '^CREATE POLICY'` = **34** (1:1 соответствие, см. Z.5.4). Forward-FK на `tenants(id)` отсутствуют (первая ссылка на стр. 517 — после CREATE TABLE на стр. 506). Шапка schema.sql:107-108 синхронизирована.
### Z.5.6. Изменение метрик Z.0 после hotfix B-5
| Метрика | До hotfix | После Z.5.4 |
|---|---|---|
| RLS-политик | 33 | **34** (+1 deal_tag_pivot) |
| Защищённых таблиц (ENABLE) | 33 | **34** (+1 deal_tag_pivot) |
---
# Запись A — v8.2 → v8.3 (05.05.2026)
**Источники изменений:**
- Параллельный аудит crm.bp-gr.ru 05.05.2026 (партии 12, 13, 14, 15).
- Прил. М v1.1 (`Analiz_originala_v8_3.md`), §3.5 — детальное обоснование.
- Открытые_вопросы v1.6 (`Открытые_вопросы_v8_3.md`), раздел 12 — Биз-14/15/16.
## A.0. Сводка
| Параметр | v8.2 | v8.3 | Δ |
|---|---|---|---|
| Таблиц | 51 | **51** | =0 (без новых таблиц — `reminders` уже была в v8.2) |
| Полей в `deals` | 14 | **12** | -2 (удалены `reminder_text`, `reminder_at`) |
| Полей в `suppliers` | 11 | **16** | +5 (capabilities) |
| Полей в `tenants` | 22 | **23** | +1 (`desired_daily_numbers`) |
| Полей в `reminders` | 9 | **11** | +2 (`assignee_id`, `completed_at`; `user_id`→`created_by`; `is_done` удалено) |
| Индексов | 80 | **81** | +1 нетто (-1 `idx_deals_reminder`, +2 `reminders`) |
| RLS-политик | 31 | **31** | =0 (RLS на `reminders` уже была) |
| Записей в `system_settings` | 22 | **25** | +3 (cron purge-deleted) |
---
## A.1. Что изменилось в коде backend (Laravel)
### A.1.1. Eloquent-модели — изменённые
**`App\Models\Reminder`** (была, перепись):
- `$fillable`: убрать `user_id`, `is_done`. Добавить: `created_by`, `assignee_id`, `completed_at`.
- `$casts`: `completed_at => 'datetime'`, `is_sent => 'boolean'`.
- **Удалить** старый scope `scopeNotDone()` — заменить на `scopeActive()` с условием `whereNull('completed_at')`.
- **Удалить** старый scope `scopeForUser($userId)` — заменить на `scopeCreatedBy($userId)` (по новому полю).
- Новый scope `scopeAssignedTo($userId)` — для будущей фичи назначения (на MVP всегда NULL).
- Relations:
- `creator()` → `belongsTo(User::class, 'created_by')` (был `user()`).
- `assignee()` → `belongsTo(User::class, 'assignee_id')` (новый, для Post-MVP).
- `deal()` → `belongsTo(Deal::class)` (без изменений).
- Метод `markCompleted()` — вместо `is_done = true` ставит `completed_at = now()`.
- Метод `isCompleted(): bool` — проверка `completed_at !== null`.
**`App\Models\Deal`:**
- `$fillable`: убрать `reminder_text`, `reminder_at`.
- `$casts`: убрать `reminder_at => 'datetime'`.
- **Удалить** accessor/mutator для `reminder_text` и `reminder_at` (если были).
- Новый relation: `reminders()` → `hasMany(Reminder::class)` (вместо одиночных полей).
- Новый accessor `latestActiveReminder()` — для UI карточки сделки (показать ближайшее активное напоминание).
- Helper `hasActiveReminder(): bool` — заменяет проверку `$deal->reminder_at !== null`.
**`App\Models\Supplier`:**
- `$fillable` дополнить: `channel`, `supports_sender_name`, `supports_keyword`, `supports_csv_upload`, `supports_domains_list`.
- `$casts`: 4 boolean-поля → `'boolean'`.
- Новый метод `availableFields(): array` — возвращает массив имён полей, которые UI должен показать в форме проекта (на основании capabilities).
- Пример: для B2 — `['sender_name', 'keyword']`; для B3 — `['sender_name']`; для B1 — `['domains_list', 'csv_upload']`.
- Новый scope `scopeByChannel(string $channel)` — для фильтрации (`sites`/`calls`/`sms`).
- Helper-метод `Supplier::intersectionCapabilities(Collection $suppliers): array` — для пересечения capabilities при выборе нескольких поставщиков (для B2+B3 → `['sender_name']`, без `keyword`).
**`App\Models\Tenant`:**
- `$fillable` дополнить: `desired_daily_numbers`.
- `$casts`: `desired_daily_numbers => 'integer'`.
- Helper `getDesiredDailyNumbers(): ?int` — для отображения в UI кабинета и в админке SaaS.
### A.1.2. Сервисы — новые/изменённые
**`App\Services\ReminderService`** (был, перепись для множественных):
- `create(Deal $deal, User $creator, array $data): Reminder` — создаёт через `$deal->reminders()->create([...])`.
- `update(Reminder $reminder, array $data): void`.
- `complete(Reminder $reminder): void` — ставит `completed_at = now()` (вместо `is_done = true`).
- `delete(Reminder $reminder): void` — soft- или hard-delete (по решению заказчика; на MVP — hard-delete, паритет с оригиналом).
- `getActiveForDeal(Deal $deal): Collection` — все активные напоминания сделки.
- `getDashboardForUser(User $user, string $filter): Collection` — фильтр `today|last|future|none` (паритет с `?reminders=...` в оригинале).
- `today`: `DATE(remind_at) = CURRENT_DATE AND completed_at IS NULL`.
- `last`: `remind_at < NOW() AND completed_at IS NULL` (просроченные).
- `future`: `remind_at >= TOMORROW AND completed_at IS NULL`.
- `none`: для `Deal` — те, у которых `reminders()->active()->count() = 0`.
**`App\Services\SupplierCapabilityService`** (новый):
- `getRelevantFieldsForProject(Project $project): array` — на основании выбранных поставщиков проекта возвращает массив релевантных полей формы.
- `validateProjectFields(Project $project, array $input): array` — server-side валидация: для проекта с B3 нельзя передать `keyword` (выбросить exception).
**`App\Console\Commands\Projects\PurgeDeleted`** (новая cron-задача, Биз-14):
- Имя: `projects:purge-deleted`.
- Расписание: из `system_settings.projects_purge_deleted_cron` (по умолчанию `0 4 * * *`).
- Условия запуска: `system_settings.projects_purge_deleted_enabled = true` (по умолчанию `false`).
- Логика: проходит по всем тенантам, для каждого вызывает `Project::onlyTrashed()->where('deleted_at', '<', now()->subDays($ttl))->forceDelete()` где `$ttl = system_settings.projects_purge_deleted_ttl_days` (по умолчанию 180).
- Логирует в `incidents_log` каждый цикл с количеством физически удалённых проектов.
- **На MVP cron включён в коде, но disabled через settings** — включается админом SaaS вручную после согласования с юристом.
### A.1.3. Контроллеры — изменённые
**`App\Http\Controllers\Api\Tenant\ReminderController`** (новый):
- `GET /api/v1/deals/{deal}/reminders` — список активных напоминаний сделки.
- `POST /api/v1/deals/{deal}/reminders` — создать.
- `PATCH /api/v1/reminders/{reminder}` — изменить.
- `POST /api/v1/reminders/{reminder}/complete` — пометить выполненным.
- `DELETE /api/v1/reminders/{reminder}` — удалить.
- `GET /api/v1/reminders/dashboard?filter=today|last|future|none` — паритет с оригиналом.
**`App\Http\Controllers\Api\Tenant\DealController`:**
- В endpoint `GET /api/v1/deals` добавить query-параметр `reminders=today|last|future|none` (паритет с `?reminders=...` оригинала).
- В endpoint `GET /api/v1/deals/{deal}` присоединить `reminders` в response через `with('reminders')`.
- Из endpoint'ов `POST` и `PATCH` для `Deal` **убрать** валидацию полей `reminder_text` и `reminder_at` (теперь только через `reminders` API).
**`App\Http\Controllers\Api\Tenant\ProjectController`:**
- В response `GET /api/v1/projects/{project}` добавить вычисленное поле `available_fields` (через `SupplierCapabilityService`).
- В endpoint'ах `POST`/`PATCH` валидация поля `keyword` зависит от `supports_keyword` выбранного поставщика.
**`App\Http\Controllers\Api\Tenant\TenantController`:**
- В response `GET /api/v1/tenant` добавить `desired_daily_numbers`.
- В endpoint `PATCH /api/v1/tenant` разрешить редактирование `desired_daily_numbers` (только админ тенанта).
**`App\Http\Controllers\SaasAdmin\TenantController`:**
- В response `GET /admin/tenants/{tenant}` отображать `desired_daily_numbers` в карточке тенанта (для саппорта).
### A.1.4. Validation Requests
**`App\Http\Requests\StoreReminderRequest`** (новый):
- `text`: `nullable|string|max:255`.
- `remind_at`: `required|date|after:now`.
- `assignee_id`: `nullable|integer|exists:users,id` (на MVP не используется).
**`App\Http\Requests\StoreProjectRequest`, `UpdateProjectRequest`:**
- Условная валидация по capabilities выбранных поставщиков — через `Rule::when()`:
```php
'keyword' => [
Rule::when(
$this->supplierSupports('keyword'),
['nullable', 'string', 'max:50'],
['prohibited']
),
],
```
**`App\Http\Requests\UpdateTenantRequest`:**
- `desired_daily_numbers`: `nullable|integer|min:1`.
### A.1.5. Vuetify-frontend — компоненты
**`<DealCard.vue>`:**
- Удалить старую панель «Напоминание» с одиночными полями `reminder_text` + `reminder_at`.
- Добавить компонент `<RemindersList>` — список активных напоминаний с возможностью добавить/редактировать/закрыть/удалить.
- Кнопка «+ Добавить напоминание» открывает модалку `<ReminderForm>`.
**`<ReminderForm.vue>`** (новый):
- Поля: `text` (textarea, лимит 255), `remind_at` (date-picker + time-picker).
- На MVP **без** полей `assignee_id`, `priority`, `channel`, `recurrence` (паритет с оригиналом).
**`<DealList.vue>`:**
- Добавить дропдаун «Задачи» в шапку списка с 4 пунктами: «Дела на сегодня» / «Просроченные дела» / «Предстоящие дела» / «Сделки без задач» (URL-параметр `reminders=today|last|future|none`).
**`<ProjectForm.vue>`:**
- При выборе поставщиков (`project_suppliers`) автоматически показывать/скрывать поля на основании `available_fields` из API.
- Если выбран B2 — показать `sender_name` и `keyword`; B3 — только `sender_name`; B1 — `domains_list` и `csv_upload`.
- Если выбраны несколько — показывать пересечение (B2+B3 → только `sender_name`).
**`<TenantSettings.vue>`** (или `<ProfilePage.vue>`):
- Добавить поле «Целевое количество лидов в день» (`desired_daily_numbers`) — number input. Подсказка: «Желаемый объём — сигнал для нашего саппорта».
**`<SaasAdminTenantCard.vue>`:**
- В админке SaaS отображать `desired_daily_numbers` в карточке тенанта (read-only для саппорта; редактируемое только для admin/superadmin).
---
## A.2. Миграция данных существующих dev-окружений
Если у вас уже развёрнуто dev-окружение со схемой v8.2 и нужно мигрировать на v8.3 без потери данных:
```sql
BEGIN;
-- 1. Миграция данных reminder_text + reminder_at в reminders
INSERT INTO reminders (tenant_id, deal_id, text, remind_at, created_by, created_at)
SELECT
d.tenant_id,
d.id,
d.reminder_text,
d.reminder_at,
COALESCE(d.manager_id, (SELECT id FROM users WHERE tenant_id = d.tenant_id LIMIT 1)),
d.received_at
FROM deals d
WHERE d.reminder_at IS NOT NULL;
-- 2. Удаление старых полей и индекса
ALTER TABLE deals DROP COLUMN reminder_text;
ALTER TABLE deals DROP COLUMN reminder_at;
DROP INDEX IF EXISTS idx_deals_reminder;
-- 3. Реструктуризация reminders: user_id → created_by, is_done → completed_at
ALTER TABLE reminders RENAME COLUMN user_id TO created_by;
ALTER TABLE reminders ADD COLUMN assignee_id BIGINT REFERENCES users(id);
ALTER TABLE reminders ADD COLUMN completed_at TIMESTAMPTZ;
ALTER TABLE reminders ADD COLUMN updated_at TIMESTAMPTZ;
-- Перенести is_done = true → completed_at = now() (приближённо)
UPDATE reminders SET completed_at = COALESCE(sent_at, created_at, NOW())
WHERE is_done = TRUE;
ALTER TABLE reminders DROP COLUMN is_done;
-- Пересоздать индексы (старые с is_done больше не валидны)
DROP INDEX IF EXISTS idx_reminders_due;
DROP INDEX IF EXISTS idx_reminders_tenant_user_due;
CREATE INDEX idx_reminders_due
ON reminders(remind_at) WHERE is_sent = FALSE AND completed_at IS NULL;
CREATE INDEX idx_reminders_deal
ON reminders(deal_id);
CREATE INDEX idx_reminders_tenant_user_active
ON reminders(tenant_id, created_by, remind_at) WHERE completed_at IS NULL;
CREATE INDEX idx_reminders_tenant_active
ON reminders(tenant_id, remind_at) WHERE completed_at IS NULL;
-- 4. Расширение suppliers
ALTER TABLE suppliers
ADD COLUMN channel VARCHAR(20) NOT NULL DEFAULT 'sites'
CHECK (channel IN ('sites','calls','sms')),
ADD COLUMN supports_sender_name BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN supports_keyword BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN supports_csv_upload BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN supports_domains_list BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE suppliers SET
channel = 'sites', supports_sender_name = FALSE, supports_keyword = FALSE
WHERE code = 'b1';
UPDATE suppliers SET
channel = 'sms', supports_sender_name = TRUE, supports_keyword = TRUE,
supports_csv_upload = FALSE, supports_domains_list = FALSE
WHERE code = 'b2';
UPDATE suppliers SET
channel = 'sms', supports_sender_name = TRUE, supports_keyword = FALSE,
supports_csv_upload = FALSE, supports_domains_list = FALSE
WHERE code = 'b3';
-- 5. tenants.desired_daily_numbers
ALTER TABLE tenants
ADD COLUMN desired_daily_numbers INT
CHECK (desired_daily_numbers IS NULL OR desired_daily_numbers > 0);
-- 6. system_settings: schema_version + 3 новых ключа
UPDATE system_settings SET value = '8.3' WHERE key = 'schema_version';
INSERT INTO system_settings (key, value, type, description) VALUES
('projects_purge_deleted_enabled', 'false', 'bool',
'Включён ли cron физического удаления soft-deleted проектов после TTL'),
('projects_purge_deleted_ttl_days', '180', 'int',
'TTL для физического удаления soft-deleted проектов (дней). 180 = 6 месяцев.'),
('projects_purge_deleted_cron', '0 4 * * *', 'string',
'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)');
COMMIT;
```
**Важно:** на проде (когда появится) — миграция через Laravel migration с явным review backend-разработчиком. Этот SQL — только для dev-окружения.
---
## A.3. Тестирование
### A.3.1. Unit-тесты, которые нужно обновить
- `tests/Unit/Models/ReminderTest.php` — переписать тесты создания/чтения/обновления (поля `created_by`, `completed_at`, scope `active`).
- `tests/Unit/Models/DealTest.php` — удалить тесты на `reminder_text`/`reminder_at`. Добавить тесты на relation `reminders()` и helper `hasActiveReminder()`.
- `tests/Unit/Models/SupplierTest.php` — добавить тесты на `availableFields()` и `intersectionCapabilities()`.
- `tests/Unit/Services/ReminderServiceTest.php` — новый тест-класс на все методы сервиса (включая фильтр-сценарии `today|last|future|none`).
- `tests/Unit/Services/SupplierCapabilityServiceTest.php` — новый.
### A.3.2. Feature-тесты
- `tests/Feature/Api/Tenant/ReminderApiTest.php` — новый: CRUD endpoints для reminder + dashboard.
- `tests/Feature/Api/Tenant/DealApiTest.php` — обновить: убрать сценарии с `reminder_text`/`reminder_at`, добавить с фильтром `?reminders=today`.
- `tests/Feature/Api/Tenant/ProjectApiTest.php` — добавить: при выборе B3 запрос с полем `keyword` должен вернуть 422.
- `tests/Feature/Console/PurgeDeletedTest.php` — новый: создать просроченные soft-deleted проекты, запустить команду, проверить что физически удалены.
### A.3.3. Browser-тесты (Dusk)
- `tests/Browser/DealCardRemindersTest.php` — добавить несколько напоминаний, отметить выполненным, удалить.
- `tests/Browser/ProjectFormSupplierFieldsTest.php` — проверить, что при смене поставщика поля динамически появляются/скрываются.
---
## A.4. Документация — что обновить
- ✅ Прил. М v1.1 — `Analiz_originala_v8_3.md` (раздел 9, §3.5).
- ✅ Открытые_вопросы v1.6 (раздел 12 с Биз-14/15/16).
- ✅ schema.sql v8.3.
- ✅ CHANGELOG записи A в этом файле `CHANGELOG_schema.md` (после оптимизации архива v8.3++ optimized 05.05.2026 объединён с CHANGELOG записи B = v8.1 → v8.2).
- ✅ README_АРХИВ_v8_3.md → v8.3++ optimized (16 файлов).
- ⏸ Прил. Б+В (ER + State machines, объединены в `Приложение_Б_В_БД_диаграммы_v8_3.md` в v8.3++ optimized) — ER-часть от v8.1, требует обновления до v8.2 + v8.3 при следующей итерации (state machines изменений не получили).
- ⏸ v8.4 narrative — раскрытие 30 решений интервью + интеграция выводов аудита партий 1–15 (см. Прил. М §4 + §9.6).
---
## A.5. Хронология изменений
- **v8.1 → v8.2** (04.05.2026): suppliers + project_suppliers + лимиты проектов + processing_restricted + incidents_log. Источник: интервью 04.05 + аудит 1–11 партий.
- **v8.2 → v8.3** (05.05.2026): reminders переписана + suppliers capabilities + tenants.desired_daily_numbers + cron purge-deleted. Источник: параллельный аудит партий 12–15.
---
*Конец CHANGELOG v8.3.*
*Источник: Прил. М v1.1, §3.5; Открытые_вопросы v1.6, раздел 12; schema.sql v8.3.*
---
# Запись B — v8.1 → v8.2 (04.05.2026)
**Источники изменений:**
- Интервью с заказчиком 04.05.2026 (OPEN-Д-1, OPEN-Д-5, OPEN-И-1).
- Аудит crm.bp-gr.ru 04.05.2026, партии 1–11 (раскрытие сущности «Поставщик», динамические лимиты).
- Прил. М v1.0 (`Analiz_originala_v8_3.md`) — детальное обоснование.
## B.0. Сводка
| Параметр | v8.1 | v8.2 | Δ |
|---|---|---|---|
| Таблиц | 47 | **51** | +4 |
| Полей в `projects` | 7 | **13** | +6 |
| Полей в `pd_subject_requests` | 11 | **12** | +1 |
| Полей в `supplier_lead_costs` | 7 | 7 | =0 (`supplier_code` → `supplier_id`) |
| Полей в `supplier_invoices` | 17 | 17 | =0 (`supplier_code` → `supplier_id`) |
| Индексов | 67 | **80** | +13 |
| RLS-политик | 29 | **30** | +1 (`project_limit_adjustments`) |
| Seed-записей в `suppliers` | — | **3** | +3 (B1/B2/B3) |
| Записей в `system_settings` | 19 | **23** | +4 |
---
## B.1. Что изменилось в коде backend (Laravel)
### B.1.1. Eloquent-модели — новые
```
App\Models\Supplier → suppliers
App\Models\ProjectSupplier → project_suppliers (m2m через through)
App\Models\IncidentsLog → incidents_log (только из админки SaaS)
App\Models\ProjectLimitAdjustment → project_limit_adjustments
```
### B.1.2. Eloquent-модели — изменённые
**`App\Models\Project`:**
- Добавить `$fillable`: `daily_limit_target`, `effective_daily_limit_today`, `region_mask`, `region_mode`, `delivery_days_mask`.
- `$casts`: `effective_limit_calculated_at => 'datetime'`.
- Новый relation: `suppliers()` через `belongsToMany(Supplier::class, 'project_suppliers')->withPivot('settings', 'is_active')`.
- Новый relation: `limitAdjustments()` через `hasMany(ProjectLimitAdjustment::class)`.
- Helper `effectiveDailyLimit(): int` — возвращает `effective_daily_limit_today ?? daily_limit_target`.
**`App\Models\PdSubjectRequest`:**
- Добавить `$casts`: `processing_restricted => 'boolean'`.
- Scope `scopeRestricted($query)` для выборки тех, у кого `processing_restricted = TRUE`.
**`App\Models\SupplierLeadCost`:**
- Удалить из `$fillable`: `supplier_code`.
- Добавить в `$fillable`: `supplier_id`.
- Новый relation: `supplier()` через `belongsTo(Supplier::class)`.
**`App\Models\SupplierInvoice`:**
- То же — `supplier_code` → `supplier_id` + relation `supplier()`.
### B.1.3. Сервисы — новые
**`App\Services\Limits\EffectiveLimitCalculator`** (см. Прил. М §3.2):
```php
public function recalculate(Project $project): int {
$tenant = $project->tenant;
$balance = $tenant->balance_rub;
$leadCost = $tenant->effective_lead_cost();
$maxByMoney = (int) floor($balance / $leadCost);
$effective = min($project->daily_limit_target, $maxByMoney);
if ($effective !== $project->effective_daily_limit_today) {
ProjectLimitAdjustment::create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'target_limit' => $project->daily_limit_target,
'effective_limit' => $effective,
'adjustment_reason' => $this->detectReason(...),
'balance_at_calc_rub' => $balance,
'lead_cost_at_calc_rub' => $leadCost,
]);
$project->update([
'effective_daily_limit_today' => $effective,
'effective_limit_calculated_at' => now(),
]);
}
return $effective;
}
```
**Триггеры вызова:**
1. Cron `limits:recalc` в 00:00 МСК для всех `is_active=TRUE` проектов (`adjustment_reason='daily_recalc'`).
2. После `BalanceTransaction::commit()` — для всех проектов тенанта (`balance_recovered` или `balance_low`).
3. После списания за лид в `ProcessWebhookJob` — если `effective < target` (`balance_low`).
4. При `ProjectCreated` event (`project_created`).
5. При `TariffChanged` event (`tariff_change`).
6. При `Project::update(['daily_limit_target' => ...])` (`target_changed`).
**`App\Services\Pd\ProcessingRestrictionGuard`:**
- Middleware / observer, проверяющий `pd_subject_requests.processing_restricted` для всех мутаций ПДн.
- При TRUE → выбрасывает `App\Exceptions\Pd\ProcessingRestrictedException`.
- См. Прил. Д v8.2.
**`App\Services\Incidents\IncidentLogger`:**
- API для админки SaaS — создание / закрытие инцидентов.
- При `type='data_breach'` — автоматическая отправка нотификации в Slack on-call + создание задачи compliance.
- См. Прил. И v8.2 раздел 6.
### B.1.4. API endpoints — новые
```
GET /api/v1/projects/{id}/effective-limit — текущий effective_daily_limit_today + причина
GET /api/v1/projects/{id}/limit-adjustments — лог автокоррекций для UI клиента (последние 30)
GET /api/v1/suppliers — публичный каталог B1/B2/B3 для селектора в форме проекта
```
**Админка SaaS:**
```
GET /admin/incidents — журнал инцидентов
POST /admin/incidents — создать (требует severity, summary, started_at)
PATCH /admin/incidents/{id} — обновить (root_cause, postmortem_url)
POST /admin/incidents/{id}/resolve — закрыть инцидент (выставить resolved_at)
GET /admin/pd-subject-requests/{id}/restrict — toggle processing_restricted (compliance)
```
### B.1.5. Job'ы и события — новые
```
App\Jobs\Limits\RecalculateProjectLimits (cron limits:recalc)
App\Events\Project\LimitAdjusted (для аудита и UI push)
App\Events\Pd\ProcessingRestrictionToggled (для аудита)
App\Events\Incident\Created
App\Events\Incident\Resolved
```
### B.1.6. UI / Vuetify
**Карточка проекта (см. Прил. М §4.5):**
- Чекбоксы поставщиков B1/B2/B3 (мульти-чекбокс из `suppliers` где `is_active=TRUE`).
- При выборе B2 — раскрытие подформы по схеме `suppliers.settings_schema` (поля `sender_name`, `keyword`).
- Number-input «Целевой дневной лимит» (`daily_limit_target`).
- Readonly-blob «Реальный лимит сегодня: X (скорректировано по балансу, см. подробнее)» с ссылкой на `/limit-adjustments`.
- Toggle «Включить/Исключить регионы» (`region_mode`).
- Дерево 8 округов (мульти-чекбокс) — преобразуется в `region_mask` на сабмите.
- 7 чекбоксов дней приёма — преобразуются в `delivery_days_mask` на сабмите.
**Админка SaaS — новые экраны:**
- `/admin/incidents` — таблица + форма создания (Прил. Г v8.2).
- `/admin/pd-subject-requests/{id}` — кнопка «Ограничить обработку» (compliance).
---
## B.2. Что изменилось в данных существующих таблиц
### B.2.1. `supplier_lead_costs`
- Все строки: `supplier_id` → ID строки `b1` в `suppliers` (бэкфил в патче).
- `supplier_code` — удалено.
- Логика чтения себестоимости: было `supplier_lead_costs.cost_rub` (snapshot из `system_settings.supplier_default_cost_rub`). Стало то же самое, но cost_rub теперь должен браться из `suppliers.cost_rub` через `supplier_id` для всех новых строк. Для старых остаётся snapshot.
### B.2.2. `supplier_invoices`
- То же — `supplier_code` → `supplier_id`.
### B.2.3. `projects`
- 6 новых полей с дефолтами:
- `daily_limit_target = 10` (10 лидов/день).
- `region_mask = 255` (все 8 округов разрешены).
- `region_mode = 'include'`.
- `delivery_days_mask = 127` (все 7 дней).
- `effective_daily_limit_today = NULL` (требует первого пересчёта).
- `effective_limit_calculated_at = NULL`.
**Важно:** после применения патча — запустить `php artisan limits:recalc --all` ОДИН РАЗ, чтобы заполнить `effective_daily_limit_today` для всех существующих проектов.
### B.2.4. `pd_subject_requests`
- Новое поле `processing_restricted = FALSE` для всех существующих строк (default).
- Compliance-админу — пройти список открытых обращений и выставить флаг там, где он по факту должен быть TRUE.
### B.2.5. `system_settings`
- 4 новых ключа: `schema_version`, `limits_recalc_cron_enabled`, `limits_recalc_minute_offset`, `limits_balance_low_log_enabled`.
---
## B.3. Что НЕ изменилось (но требует внимания в v8.4)
### B.3.1. Схема статусов сделок
Свободная state-machine подтверждена аудитом (партия 11). У нас тоже свободная — изменений не нужно. Будет явно зафиксировано в narrative §8 v8.4.
### B.3.2. `deals`
Карточка сделки в оригинале не имеет файлов / задач (партия 11.6). У нас тоже — паритет. Без изменений в схеме.
### B.3.3. Биллинг
В оригинале мульти-кошельковая модель (4 счётчика), у нас — одновалютная. Заказчик подтверждает упрощение (Биз-11) — без изменений.
### B.3.4. RLS на `deals` для `processing_restricted`
В этом патче RLS-политика на `deals` для блокировки выборки по субъектам с `processing_restricted=TRUE` **НЕ** добавлена. Причина: сложная логика связи (deal через phone/email с pd_subject_requests). Будет добавлено в v8.3 после уточнения с юристом и compliance-админом.
---
## B.4. Шаги применения
### B.4.1. Установка с нуля (новые dev / staging окружения)
```bash
# 1. Создать БД
createdb -E UTF8 liderra
# 2. Применить консолидированную schema.sql v8.2
psql $DB_URL -f schema.sql
# 3. Smoke-проверки
psql $DB_URL -c "SELECT value FROM system_settings WHERE key = 'schema_version';"
# Ожидается: 8.2
psql $DB_URL -c "SELECT COUNT(*) FROM suppliers WHERE code IN ('b1','b2','b3');"
# Ожидается: 3
psql $DB_URL -c "SELECT COUNT(*) FROM lead_statuses;"
# Ожидается: 14
psql $DB_URL -c "SELECT COUNT(*) FROM tariff_plans WHERE is_active = TRUE;"
# Ожидается: 4
```
### B.4.2. Миграция с v8.1 на v8.2 (существующие dev/staging)
Поскольку в проекте сейчас используется **консолидированный** подход (один файл schema.sql вместо последовательности патчей), для миграции существующих окружений с v8.1 нужно:
**Вариант А — пересоздание БД (рекомендуется для dev/staging до публичного запуска):**
```bash
# 1. Backup данных, которые нужно сохранить (если есть)
pg_dump --data-only $DB_URL > data-backup-$(date +%Y%m%d).sql
# 2. Drop & recreate
dropdb liderra && createdb -E UTF8 liderra
psql $DB_URL -f schema.sql
# 3. Восстановить нужные данные (если есть)
# Внимание: формат supplier_lead_costs изменился (supplier_code → supplier_id),
# при восстановлении из старого dump'а понадобится трансформация.
```
**Вариант Б — ручная миграция** (только если данные нельзя терять):
- Сравнить v8.1 и v8.2 (например, через `git diff schema.sql.v8.1 schema.sql`).
- Применить вручную ALTER'ы из дельты.
- Бэкфил `supplier_lead_costs.supplier_id ← suppliers WHERE code='b1'`.
- В этом случае может быть полезно сгенерировать diff-патч на лету:
```bash
diff -u schema.sql.v8.1 schema.sql > migration-v8.1-to-v8.2.diff
```
### B.4.3. Production (когда дойдём)
К моменту production-запуска проект ещё не имеет реальных данных, поэтому применяется как «установка с нуля» (вариант А). Если позже потребуется миграция production → следующая версия — соберу отдельный патч-файл с ALTER'ами (см. также Прил. И v8.2 раздел 5 «Migration runbook»).
После применения:
- Запустить `php artisan limits:recalc --all` — заполнит `effective_daily_limit_today` для всех существующих проектов.
- Мониторить Sentry на `ProcessingRestrictedException` — это нормальные ожидаемые exception'ы при попытках работать с restricted-субъектами.
---
## B.5. Откат
Поскольку файл консолидированный, отката «как такового» нет — есть только восстановление из backup'а.
**Рекомендация:** на dev/staging — `pg_dump` перед каждым применением schema.sql.
Если нужно «вернуться на v8.1» на свежем deploy:
1. Достать предыдущую версию schema.sql из git (`git show <commit>:schema.sql > schema.sql.v8.1`).
2. Drop & recreate БД.
3. Применить старую schema.
При наличии данных — отдельный rollback-патч можно собрать по запросу (DROP TABLE incidents_log/suppliers/project_suppliers/project_limit_adjustments + DROP COLUMN из projects/pd_subject_requests + восстановление supplier_code).
---
## B.6. Связь с Прил. М
Все 7 групп изменений детально обоснованы в `Analiz_originala_v8_3.md` (Прил. М v1.0):
| Группа изменений | Раздел Прил. М |
|---|---|
| 1. `processing_restricted` | §3.3 |
| 2. `incidents_log` | §3.3 |
| 3. `suppliers` | §2.1, §3.1 |
| 4. `project_suppliers` | §2.1, §3.1 |
| 5. Миграция `supplier_code → supplier_id` | §3.1 |
| 6. Расширение `projects` | §2.2, §3.2 |
| 7. `project_limit_adjustments` | §2.2, §3.2 |
---
*Версия changelog: 1.0 от 04.05.2026.*
---
*Журнал ведётся при каждом изменении `schema.sql`. Новые записи добавляются сверху, перед записью A.*
*Документ объединён из CHANGELOG-v8_2.md и CHANGELOG-v8_3.md в рамках оптимизации архива v8.3++ → v8.3++ optimized 05.05.2026.*
*Версия документа: 1.0 от 05.05.2026.*