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