From 36c8ced41eb975ea701083cee6be90b4c1beba16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 31 Jul 2026 06:04:56 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D1=81=D0=BC=D1=81-=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82):=20=D1=81=D1=83=D0=B4=D1=8C=D0=B1=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=BE=D0=B9=20+=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Этап 5, Task 2. Схема v9.23 и v9.24. Графа судьбы (миграция 101100): семь колонок у client_sms_messages — delivery_status, delivered_at, delivery_checked_at, delivery_raw, provider_cost, provider_parts, refunded_at, плюс индекс «кого спрашивать дальше». Почему графа, а не переписывание status: по status считаются ДЕНЬГИ и месячный объём клиента (ClientSmsVolumeCounter берёт ровно sent), его же складывает сторож зависших и разбирают два словаря подписей на экране. Заменив sent на delivered, мы обнулили бы клиенту накопление за месяц и удешевили бы цену задним числом. Значит status остаётся фактом ПЕРЕДАЧИ, а судьба живёт рядом (В-202). Цена оператора (provider_cost) хранится КАК ЕСТЬ: единицы поля cost у МТС неизвестны (В-211), в рубли не переводится и ни во что не подставляется. Право служебной роли (миграция 101200): GRANT UPDATE на client_sms_messages. Команда опроса судьбы кросс-клиентская, ходит служебным соединением и ПРАВИТ существующую строку — новых не пишет намеренно (В-204: новые строки делали бы зависшую рассылку «живой» для сторожа, и деньги остались бы замороженными). Без права на бою команда падала бы каждые десять минут; локально не видно никогда — dev и тесты ходят суперпользователем (В-126/В-127). Проверено: - сторож MigrationGrantsTest расширен и проверен ВЫРЕЗОМ: гашение GRANT в миграции красит его; - изоляция цела — запросом к базе после наката: RLS true/true, политика tenant_isolation на месте, права ролей ровно ожидаемые, все семь колонок есть; - весь модуль 327/327 (было 320, +7 читателя судьбы), 13 пачек, все с первой попытки; phpstan ровно 2 чужие давние, pint чисто. --- app/app/Models/ClientSmsMessage.php | 19 +++++ ...00_add_delivery_to_client_sms_messages.php | 68 ++++++++++++++++ ..._grant_supplier_worker_update_messages.php | 48 +++++++++++ .../Feature/ClientSms/MigrationGrantsTest.php | 8 +- cspell-words.txt | 4 + db/CHANGELOG_schema.md | 79 ++++++++++++++++++- 6 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 app/database/migrations/2026_08_01_101100_add_delivery_to_client_sms_messages.php create mode 100644 app/database/migrations/2026_08_01_101200_grant_supplier_worker_update_messages.php diff --git a/app/app/Models/ClientSmsMessage.php b/app/app/Models/ClientSmsMessage.php index e51174a0..79c4eb1d 100644 --- a/app/app/Models/ClientSmsMessage.php +++ b/app/app/Models/ClientSmsMessage.php @@ -74,6 +74,15 @@ class ClientSmsMessage extends Model 'segments', 'provider_message_id', 'error', + // Судьба сообщения (строка листа 5.1, Этап 5). 🔴 Не путать со `status`: + // тот означает «мы отдали оператору», и по нему считаются деньги (В-202). + 'delivery_status', + 'delivered_at', + 'delivery_checked_at', + 'delivery_raw', + 'provider_cost', + 'provider_parts', + 'refunded_at', ]; protected function casts(): array @@ -86,6 +95,16 @@ class ClientSmsMessage extends Model // Сколько СМС в этом сообщении: длинное письмо — это два, и платит клиент // за два. Пусто у старых записей = одно (В-108, В-109). 'segments' => 'integer', + // Судьба сообщения (строка листа 5.1). Живёт РЯДОМ со `status`, а не + // вместо него: `status` — это «мы отдали оператору» и по нему считаются + // деньги и месячный объём, судьба — «дошло ли до человека» (В-202). + 'delivered_at' => 'immutable_datetime', + 'delivery_checked_at' => 'immutable_datetime', + 'refunded_at' => 'immutable_datetime', + 'provider_parts' => 'integer', + // 🔴 Число оператора КАК ЕСТЬ, единицы неизвестны (В-211) — в рубли не + // переводится и ни во что не подставляется до живой сверки. + 'provider_cost' => 'decimal:4', ]; } diff --git a/app/database/migrations/2026_08_01_101100_add_delivery_to_client_sms_messages.php b/app/database/migrations/2026_08_01_101100_add_delivery_to_client_sms_messages.php new file mode 100644 index 00000000..e54d7bf6 --- /dev/null +++ b/app/database/migrations/2026_08_01_101100_add_delivery_to_client_sms_messages.php @@ -0,0 +1,68 @@ +string('delivery_status', 16)->nullable(); + // Когда дошло до человека (userDeliveryDate в ответе МТС). + $table->timestampTz('delivered_at')->nullable(); + // Когда мы последний раз спрашивали судьбу — чтобы не долбить оператора. + $table->timestampTz('delivery_checked_at')->nullable(); + // Слово оператора, которого мы не поняли, — для разбора, не для логики. + $table->string('delivery_raw', 32)->nullable(); + // 🔴 Число из ответа МТС КАК ЕСТЬ. Единицы поля `cost` неизвестны (журнал + // В-211 записал только сам факт его наличия), перевод в рубли появится + // после живой сверки с известной ценой. Ни во что не подставлять. + $table->decimal('provider_cost', 14, 4)->nullable(); + // На сколько частей разбил сообщение сам оператор — сверяем со своим счётом. + $table->smallInteger('provider_parts')->nullable(); + // Когда вернули клиенту деньги за это сообщение (решение владельца В-198). + // Вторая опора идемпотентности рядом с ключом события в кошельке. + $table->timestampTz('refunded_at')->nullable(); + + // Кого спрашивать дальше: незакрытые отправленные, свежее трёх суток. + $table->index(['delivery_status', 'created_at'], 'client_sms_messages_delivery_idx'); + }); + } + + public function down(): void + { + Schema::table('client_sms_messages', function (Blueprint $table) { + $table->dropIndex('client_sms_messages_delivery_idx'); + $table->dropColumn([ + 'delivery_status', + 'delivered_at', + 'delivery_checked_at', + 'delivery_raw', + 'provider_cost', + 'provider_parts', + 'refunded_at', + ]); + }); + } +}; diff --git a/app/database/migrations/2026_08_01_101200_grant_supplier_worker_update_messages.php b/app/database/migrations/2026_08_01_101200_grant_supplier_worker_update_messages.php new file mode 100644 index 00000000..575d581b --- /dev/null +++ b/app/database/migrations/2026_08_01_101200_grant_supplier_worker_update_messages.php @@ -0,0 +1,48 @@ + ['SELECT', 'UPDATE', 'DELETE'], - 'client_sms_messages' => ['SELECT'], + // UPDATE добавлен в Этапе 5 (строки листа 5.1–5.2, миграция `2026_08_01_101200`). + // Команда `client-sms:poll-delivery` спрашивает у оператора судьбу сообщений и + // ПРАВИТ существующую строку журнала — новых не пишет намеренно (В-204: новые + // строки делали бы зависшую рассылку «живой» для сторожа, и деньги остались бы + // замороженными). Без этого права на бою команда падает «нет доступа»; локально + // не видно НИКОГДА — dev и тесты ходят суперпользователем (класс В-126/В-127). + 'client_sms_messages' => ['SELECT', 'UPDATE'], ], ]; diff --git a/cspell-words.txt b/cspell-words.txt index 7f80f94c..95b7671b 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2440,3 +2440,7 @@ kanal Директа яндексовы поллинг +админской +неушедшие +тенантного +энумератор diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 3314078d..e4d53ca7 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -8,6 +8,81 @@ > параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор), > поэтому при сведении они перенумерованы. Содержание не менялось. +## v9.24 (2026-08-01) — Клиентская СМС, Этап 5: право ПРАВИТЬ журнал сообщений служебной ролью + +**Миграция:** `2026_08_01_101200_grant_supplier_worker_update_messages.php`. Ни таблиц, ни колонок не заводит — только одно право. + +| Роль | Таблица | Право | Зачем | +|---|---|---|---| +| `crm_supplier_worker` | `client_sms_messages` | `UPDATE` | Команда `client-sms:poll-delivery` спрашивает у оператора судьбу сообщений и правит существующую строку журнала (строки листа 5.1–5.2, решение В-204) | + +**Зачем вообще.** Отчёт о доставке правит СУЩЕСТВУЮЩУЮ строку, новых не пишет, и это не вопрос +аккуратности: сторож зависших меряет движение рассылки по `MAX(created_at)` журнала сообщений +(`WatchStuckClientSmsCommand`). Новые строки от отчётов делали бы **зависшую рассылку «живой»** — +сторож перестал бы её срывать, а деньги клиента остались бы замороженными. Правка строки меняет +`updated_at`, а его сторож не смотрит. + +Команда кросс-клиентская (обходит все тенанты), поэтому идёт служебным соединением `pgsql_supplier`. +До Этапа 5 служебная роль журнал сообщений только читала. + +🔴 **Без права на бою команда ПАДАЕТ** «нет доступа к таблице» — каждые десять минут. Локально не +видно НИКОГДА: dev и тесты ходят суперпользователем (класс В-126/В-127). + +🔴 **Право — одна из ДВУХ опор, и цена отказа у них разная** (В-181): нет права → падение, видно +(сторож расписания запишет отказ); нет разрешающей политики `srv_bypass` из +`db/03_service_bypass_policies.sql` → команда «успешно» правит НОЛЬ строк, тихо. Второе опаснее. +Перезапуск `db/03` при выкате — несущая опора этой команды, а не подстраховка. + +**Рабочей роли право не нужно:** она правит журнал под пометкой клиента из кабинета и `UPDATE` у +неё есть с Этапа 3 (миграция `2026_08_01_100400`). + +**Сторож.** `MigrationGrantsTest` расширен: у `crm_supplier_worker` на `client_sms_messages` теперь +ожидаются `SELECT, UPDATE`. Проверка спрашивает саму базу (`has_table_privilege`). Проверено +вырезом: гашение `GRANT` в миграции красит сторож. + +**Повторный накат.** `GRANT` существующего права — не ошибка. 🔴 Оговорка семейства (В-131): в SQL, +который печатает `migrate --pretend`, защиты от повторного запуска нет. + +--- + +## v9.23 (2026-08-01) — Клиентская СМС, Этап 5: СУДЬБА СООБЩЕНИЯ отдельной графой + +**Миграция:** `2026_08_01_101100_add_delivery_to_client_sms_messages.php`. Таблиц не заводит; +у `client_sms_messages` +7 колонок и +1 индекс. + +| Колонка | Тип | Зачем | +|---|---|---| +| `delivery_status` | `varchar(16)` NULL | Судьба: `sending` / `delivered` / `not_delivered` / `not_sent` | +| `delivered_at` | `timestamptz` NULL | Когда дошло до человека (`userDeliveryDate` у МТС) | +| `delivery_checked_at` | `timestamptz` NULL | Когда последний раз спрашивали оператора | +| `delivery_raw` | `varchar(32)` NULL | Слово оператора, которое мы не поняли — для разбора, не для логики | +| `provider_cost` | `decimal(14,4)` NULL | Число из ответа МТС **как есть**; единицы неизвестны (В-211), в рубли не переводится | +| `provider_parts` | `smallint` NULL | На сколько частей разбил сообщение сам оператор — сверяем со своим счётом | +| `refunded_at` | `timestamptz` NULL | Когда вернули клиенту деньги за недоставленное (решение владельца В-198) | + +**Индекс:** `client_sms_messages_delivery_idx (delivery_status, created_at)` — по нему команда +опроса выбирает, кого спрашивать дальше: незакрытые и свежее трёх суток. + +🔴 **Почему отдельная графа, а не переписывание `status`** (решение В-202). По `status` считаются +ДЕНЬГИ и месячный объём клиента: `ClientSmsVolumeCounter` берёт ровно `sent`, сторож зависших +складывает `sent + fake_sent`, журнал авто-СМС считает «ушло» тоже ровно `sent`, и оба словаря +подписей на экране разбирают те же слаги. Заменив `sent` на `delivered`, мы обнулили бы клиенту +накопление за месяц и удешевили бы ему цену задним числом. Значит `status` остаётся фактом +ПЕРЕДАЧИ («мы отдали оператору»), а судьба живёт рядом. + +🔴 **Почему `provider_cost` не переводится в рубли.** Единицы поля `cost` в ответе МТС нам +неизвестны — живая проба 30.07 (В-211) записала только сам факт его наличия. Выдумывать ответ +внешней стороны запрещено, поэтому число сохраняется как есть и ни во что не подставляется до +живой сверки с известной ценой. + +**Изоляция.** Таблица уже под RLS (`ENABLE` + `FORCE`, политика `tenant_isolation`) с момента +создания — новые колонки этого не меняют. Проверено запросом к базе после наката: RLS +`true / true`, политика на месте. + +**GRANT не нужен:** новые колонки наследуют привилегии таблицы (в отличие от новых таблиц). + +--- + ## v9.22 (2026-08-01) — Клиентская СМС, Этап 4: право УДАЛЯТЬ строки журнала сообщений рабочей ролью **Миграция:** `2026_08_01_101000_grant_app_user_delete_messages.php`. Ни таблиц, ни колонок не заводит — только одно право. @@ -276,7 +351,7 @@ dev и тесты ходят суперпользователем. Класс В **Почему этого не было видно.** Локально и в тестах подключение идёт суперпользователем, роли не применяются. На бою команда падала бы с `permission denied` каждые 15 минут — и камчатская -часть рассылок не дошлалась бы никогда. Тот же класс, что блокер v9.07 (права на счётчики). +часть рассылок не дошла бы никогда. Тот же класс, что блокер v9.07 (права на счётчики). **Сторож.** `tests/Feature/ClientSms/MigrationGrantsTest.php` расширен: спрашивает саму базу (`has_table_privilege`) про эти три таблицы для служебной роли. Доказано вырезанием дважды — @@ -609,7 +684,7 @@ IS NOT NULL`, под запрос джоба «дай созревшие это — в `client_sms_campaigns` добавлена колонка: - `with_optout_link` boolean **NOT NULL DEFAULT true** — дописывать ли к тексту - хвост ` Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13). + хвост `Отказ: liderra.ru/s/{token}` (спека §9.2, строки приёмочного листа 1.10–1.13). **Почему DEFAULT true.** Рассылка без возможности отказаться — юридический риск; он выше, чем недовольство ценой. Снять галочку клиент может, но это его явное