# 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, партии 12–15. См. ниже §A. - **v8.2 (04.05.2026)** — после интервью с заказчиком + аудита партий 1–11. См. ниже §B. **Связано:** - `Прил_М_Analiz_originala_v8_3.md` v1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.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_')`. Закрывает риск пропуска audit-записи при экспорте лидов из app-кода. ## Y.5. Новая роль `crm_audit_writer` (OPEN-И-15 + OPEN-И-23) ```sql CREATE ROLE crm_audit_writer LOGIN PASSWORD ''; 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) — спринты 14–15 (как и в 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 — компоненты **``:** - Удалить старую панель «Напоминание» с одиночными полями `reminder_text` + `reminder_at`. - Добавить компонент `` — список активных напоминаний с возможностью добавить/редактировать/закрыть/удалить. - Кнопка «+ Добавить напоминание» открывает модалку ``. **``** (новый): - Поля: `text` (textarea, лимит 255), `remind_at` (date-picker + time-picker). - На MVP **без** полей `assignee_id`, `priority`, `channel`, `recurrence` (паритет с оригиналом). **``:** - Добавить дропдаун «Задачи» в шапку списка с 4 пунктами: «Дела на сегодня» / «Просроченные дела» / «Предстоящие дела» / «Сделки без задач» (URL-параметр `reminders=today|last|future|none`). **``:** - При выборе поставщиков (`project_suppliers`) автоматически показывать/скрывать поля на основании `available_fields` из API. - Если выбран B2 — показать `sender_name` и `keyword`; B3 — только `sender_name`; B1 — `domains_list` и `csv_upload`. - Если выбраны несколько — показывать пересечение (B2+B3 → только `sender_name`). **``** (или ``): - Добавить поле «Целевое количество лидов в день» (`desired_daily_numbers`) — number input. Подсказка: «Желаемый объём — сигнал для нашего саппорта». **``:** - В админке 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 :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.*