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

91 KiB
Raw Blame History

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.

  • 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_notier_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 (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, оба про работу/коммуникации):

    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).

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-БД:

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-БД:

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) — не вмещается.

Изменение:

-- До
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 приведена к тому же паттерну.

Деплой:

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():

$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):

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 NULLCREATE 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 = trueINSERT 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)

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)

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)

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) доступа НЕ должно иметь даже теоретически.

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) — отложены до спринта 1415 (старт реализации Уровня 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_idimpersonation_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

Связь dealsdeal_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_idcreated_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():

    '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 без потери данных:

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_codesupplier_id)
Полей в supplier_invoices 17 17 =0 (supplier_codesupplier_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_codesupplier_id + relation supplier().

B.1.3. Сервисы — новые

App\Services\Limits\EffectiveLimitCalculator (см. Прил. М §3.2):

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_codesupplier_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 окружения)

# 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 до публичного запуска):

# 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-патч на лету:

    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.