- 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>
91 KiB
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.phptenants.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')+ CHECKchk_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_leadsdefense-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 CHECKchk_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_logSaaS-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_chargesappend-only ledger списаний за каждый доставленный лид. Колонки: id, tenant_id, deal_id, deal_received_at, tier_no (smallint), price_per_lead_kopecks (uint), charged_at, created_at. Composite FKlead_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 — RLStenant_isolationENABLE+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_tiersSaaS-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_projectsSaaS-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 indexidx_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.mdv1.1 — обоснование изменений v8.3 (§3.5) и v8.2 (§3.1–3.3).Открытые_вопросы_v8_3.mdv1.12 — закрытие 27 вопросов аудита C, §13.10 — источник изменений v8.5.README_АРХИВ_v8_4.md— состав архива.CRM_bp-gr_Инструкция_v8_4.mdv8.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. Изменения
- P0-02: Добавлены
ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITYиCREATE POLICY tenant_isolation ON impersonation_tokens(схема ~строка 540). - O-perf-02: Добавлен индекс
idx_failed_webhook_jobs_logнаfailed_webhook_jobs(webhook_log_id). - 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 мог:
- показывать unread-счётчик у иконки колокольчика (даже если user'а нет в момент события);
- накапливать историю «10 последних» при заходе на страницу;
- сохранять прочитанные/непрочитанные между сессиями.
Что изменилось:
-
Новая таблица
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() ); -
Индекс
idx_in_app_notifications_user_unread (user_id, created_at DESC) WHERE read_at IS NULL— основной UI-флоу «непрочитанные user'а». -
Индекс
idx_in_app_notifications_user_recent (user_id, created_at DESC)— «последние 50» (с прочитанными). -
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 сpayloadcastarray,read_atcastdatetime.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-defaultnew_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 удалённые сделки возвращались.
Что изменилось:
deals.deleted_at TIMESTAMPTZ— новая колонка. NULL = живая сделка,not null= soft-deleted (момент удаления).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— новый endpointDELETE /api/deals?tenant_id=X&ids[]=...: bulk-updatedeleted_at=NOW()через RLS + defense-in-depthwhere(tenant_id). ЗаписьActivityLog event=deal.deletedдля каждой удалённой сделки.
Frontend changes:
dealsApi.bulkDeleteDeals(payload)— DELETE helper.DealsView::applyBulkDeleteasync: 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.mdv1.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. БезDEFERRABLEFK проверяется immediate — нарушается до момента INSERT вdeals.
Эволюция решения (две стадии):
- Стадия 1 — DEFERRABLE INITIALLY DEFERRED FK (что попало в schema.sql v8.7). Каноничный PG-паттерн для composite FK с child-first INSERT'ом в одной транзакции. В bare-транзакции (production worker) — работает: constraint проверяется на COMMIT.
- Стадия 2 — pivot на advisory lock (что попало в production-код). При запуске Pest с
DatabaseTransactionstrait 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.mdv1.21 — блок «CTO-17 addendum» (08.05.2026 поздний вечер).CRM_bp-gr_Инструкция_v8_5.md §11(in-place hygiene) — DDLwebhook_dedup_keysсинхронизирован с DEFERRABLE.CLAUDE.mdv1.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 отклонил DDLCREATE UNIQUE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS NOT NULL(schema.sql:1263v8.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(одна транзакция, одна вставка). - Стало: двустадийная операция в одной транзакции:
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;- Если
is_new = true→INSERT INTO deals ...с pre-allocateddeal_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'+ CHECKIN ('manual','round_robin','least_loaded'). MVP = manual; round_robin/least_loaded зарезервированы для Post-MVP.projects.ttfr_target_minutes(Биз-18)INT NOT NULL DEFAULT 15+ CHECKBETWEEN 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'+ CHECKIN ('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+ CHECKBETWEEN 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+ CHECKBETWEEN 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). Для cronleads: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.phpbefore_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'].
- Пример: для B2 —
- Новый 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(теперь только черезremindersAPI).
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, scopeactive).tests/Unit/Models/DealTest.php— удалить тесты наreminder_text/reminder_at. Добавить тесты на relationreminders()и helperhasActiveReminder().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+ relationsupplier().
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;
}
Триггеры вызова:
- Cron
limits:recalcв 00:00 МСК для всехis_active=TRUEпроектов (adjustment_reason='daily_recalc'). - После
BalanceTransaction::commit()— для всех проектов тенанта (balance_recoveredилиbalance_low). - После списания за лид в
ProcessWebhookJob— еслиeffective < target(balance_low). - При
ProjectCreatedevent (project_created). - При
TariffChangedevent (tariff_change). - При
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 окружения)
# 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:
- Достать предыдущую версию schema.sql из git (
git show <commit>:schema.sql > schema.sql.v8.1). - Drop & recreate БД.
- Применить старую 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.