fdbff6e2d40e36c5abf97dfe7bbde69c05ee967c
216 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
337f6b9897 |
feat телеграм: дата старта показов, признак живости системы и человеческий язык ошибок
Дата старта (решение владельца 06.08.2026). Портал не передаёт кабинету МТС ни одной даты — их ставит кабинет своими умолчаниями. Живой прогон показал: старт оказался ЗАВТРАШНИМ, тогда как экран обещал клиенту показы «7 дней», подразумевая сегодня. Робот читает день начала из ТОЙ ЖЕ строки списка, куда и так ходит за вердиктом — ни одного лишнего захода в кабинет; портал хранит его в client_tg_campaigns.starts_on и показывает клиенту «Показы начнутся 7 августа». Проверено на ЖИВОМ кабинете: три задания подряд вернули startDate 2026-08-07 по кампании МТС 2237821. Мастер перестал молчать о том, что день начала ставит кабинет, а не мы. Ф-2, карточка приёмки Т-Ф4. У кампаний в движении видно «Проверяли 5 минут назад». Считаются только ЗАКОНЧЕННЫЕ проверки, включая неудачные: задание в очереди работой не является, а неудачная проверка — всё равно признак жизни. Именно в такой тишине владелец 36 часов не знал, что робот вообще не может войти в кабинет. Ф-3. Имена полей в ошибках формы по-русски: «Лимит на объявление не может быть меньше 1 ₽» вместо «Поле budget cap rub должно быть не меньше 1». Серая кнопка «Запустить» называет причину и шаг, куда вернуться, а не гаснет молча. Карточки Т-Р3 и Т-Р4 закрыты тестами (живьём не воспроизвести). Попутно найдено: обе защиты, стерегущие ЕДИНСТВЕННОЕ место траты живых денег роботом, были без единого теста. Сторожа доказаны вырезанием. Тесты: ClientTg 378 зелёных, экраны 2158, робот 197. Статанализ 0, стиль 0, типы чисто. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2ea204e3b3 |
docs схема: сторож видит имя по пути, а не по окончанию
Прежде имя опознавалось только по окончанию, и сторож был слеп к App\Services\Db\GrantsConsistencyChecker в db/02_grants.sql. Одна и та же ложь, в одном месте, ловилась или нет по последнему слову. Скверно вдвойне: чем незнакомее окончание, тем вероятнее, что класса и правда нет. Теперь имя с путём считается именем независимо от окончания. Выбор доказан замером четырёх правил: 17, 35, 40 и 348 срабатываний. Любое слово-верблюд отпадает по числу — в шум идут PostgreSQL, SaaS и имена тестов. Список окончаний для голых имён расширен: цена — одно лишнее имя в долге, находка — два имени в самом db/schema.sql, которых прежний список не видел. Известная граница названа вслух внутри сторожа: голое имя с незнакомым окончанием он не увидит, лечится привычкой писать имя с путём. Починил заодно две свои ошибки того же рода. Правило про запрет обработки работало по окну в восемь строк и давало ложное красное на десятке посторонних имён — сведено к тому же понятию куска, мерка в стороже теперь одна на всё. И существующими считались классы только из app/app: собственные проверки проекта сторож посчитал бы несуществующими. Добавлены app/tests и app/database. Перечень долга пересчитан: 28 имён вместо 9, почти весь прирост дал один раздел журнала — план мая 2026. Над перечнем поставлена оговорка: замерено, что класса с таким именем нет, и НЕ замерено, сделана ли работа под другим именем. Показан красным семью ножами. Сторож поймал собственного автора: полный прогон покраснел на абзаце, который я сам дописал в журнал час назад. Полный прогон: 5011 проверок, красных 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82503073cc |
docs схема: сторож стережёт все бумаги про базу, а не одну
Прежняя версия читала один db/schema.sql. Дыра хуже, чем пропущенный файл: два из семи мест лжи, которые эта же работа и чинила, жили в db/CHANGELOG_schema.md. Сторож берёг ту бумагу, где ложь уже поправлена, и не берёг ту, где она жила. Список бумаг больше не память, а замер: берётся с диска — всё под db/, что sql или md, включая вложенные папки с сырыми миграциями. Сегодня это 14 бумаг. Новая бумага попадёт под охрану сама, править сторожа не надо. Пропускаются скрытые файлы и временные слепки tmp.sql, иначе дубль канона считался бы второй бумагой и удваивал долг. Журнал схемы помнит прошлое, и первая версия правила покраснела на моей же честной записи v9.71. Глушить сторожа не стал: правило простое и проверяемое — кто называет несуществующее имя, тот пишет НЕ СУЩЕСТВУЕТ тем же куском текста. В SQL кусок это подряд идущие комментарии, в разметке абзац, причём строка таблицы стоит сама за себя. Запись v9.71 переписана по этому правилу. Имена кусков кода теперь ищутся не только в app/app, но и по карте классов Composer. Без неё QueryException из фреймворка попадал в наш долг, то есть сторож врал про чужой класс. Перечень известного долга пересчитан по всем бумагам: девять имён вместо трёх, у каждого сказано где живёт и почему не поправлено. Показан красным пятью ножами: schema_modules, журнал схемы, сырая миграция, schema.sql и отдельно снятие пометки с моей честной строки. Отдельно назвал границу мерки: ложь ВНУТРИ честного куска проходит зелёным, и это выбрано сознательно. Полный прогон: 5011 проверок, красных 0. Число не изменилось — проверок по-прежнему две, они просто читают больше. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8481b68c0 |
docs схема: седьмое место лжи и живой сторож канона
Седьмое место — шапка столбца sso_provider у таблицы админов. Там стояло голое обещание «Логика guard'ов — в SaasAdminAuthService», без единой оговорки. Класса нет. Место опаснее прочих шести: речь об аварийном входе, который нарочно обходит единый вход. Ни SSO, ни обязательный второй ключ, ни ограничение аварийного входа кодом не проверяются. Сторож заведён отдельной новой проверкой портала: app/tests/Unit/Schema/KanonNeObeshchaetOhrannikaTest.php. Общих файлов не трогает, базы не требует, ходит сам при каждом полном прогоне. Краснеет, когда канон называет поимённо кусок кода портала, которого нет в проекте, а рядом не сказано, что его нет. Перечень известного долга внутри проверки видимый, у каждой строки сказано почему. Это не разрешение, а замер точным числом: нельзя ни дописать упоминание разрешённому имени, ни оставить строку, когда долг закрыли. На строках про запрет обработки перечень не действует вовсе. Показан красным тремя ножами. Второй нож вскрыл дыру в самом стороже: мерка «пометка в пределах восьми строк» давала ложное зелёное — новая ложь укрывалась за честной пометкой соседнего абзаца через две строки живого SQL. Правило переделано принципиально: пояснение засчитывается, только если от имени до него идут одни пояснения. Полный прогон: 5011 проверок, красных 0, ровно плюс две мои. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e54be8adfc |
docs приёмка: отчёт работы 3 дописан — прогон, сторож, разбор задания
Полный прогон проверок портала на своей базе liderra_testing_kanon: 5009 проверок, 5005 прошли, красных 0, код возврата 0. Тестовая база собрана с нуля из поправленного канона: 192 миграции при 192 файлах, новая подпись доехала до базы дословно. Значит правка внутри COMMENT ON COLUMN исполняемый текст не сломала. Счётчики шапки канона не изменились: таблиц 99, указателей 144, политик 48, функций 5, триггеров 15 — до и после одинаково. Сторож показан красным трижды, каждое место по отдельности, и снова зелёным. Честно сказано, что установить его автоматически нельзя без чужих файлов, и попрошено разрешение. Временная база снесена, снос проверен командой. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b04c3e92d2 |
docs схема: канон перестал обещать охранника, которого нет
Решение владельца Р103 от 06.08.2026 — лечим бумагу, охранника не строим. Поле pd_subject_requests.processing_restricted существует, а обещанного каноном охранника App\Services\Pd\ProcessingRestrictionGuard и исключения App\Exceptions\Pd\ProcessingRestrictedException в проекте нет вовсе. Портал по этому флагу не запрещает ничего. Мест лжи оказалось шесть, а не четыре: сверх названных нашлись два места про вход под клиента, обещающие несуществующий SaasAdminAuthService. Правка только текстовая. Структура базы не тронута: счётчики таблиц, указателей, политик, функций и триггеров не изменились. db/schema.sql — 4 места: шапка pd_subject_requests, COMMENT ON COLUMN, пункт Ю-9 в списке решений v8.5, impersonation_tokens.second_approver_id. db/CHANGELOG_schema.md — 2 пометки в старых разделах + новая датированная запись v9.71 со ссылкой на Р103. Боевая база не тронута: подпись к столбцу там прежняя, менять только с отдельного разрешения владельца. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b401298d8 |
feat: приём записей клиента для обучения робота обзвона — З-2.3
Клиент отдаёт нам записи СВОИХ холодных разговоров. Это голоса живых людей, которые об этом не знают и согласия нам не давали. До этой правки принять их было негде и нечем: положить так, чтобы клиент А не открыл запись клиента Б, было некуда; срока жизни у записей не было; обращение к чужому голосу не оставляло следа. Что заведено: - таблица obzvon_materialy_klienta — канон в db/schema_modules.sql раздел 24, журнал схемы v9.70. Изоляция клиентов защитой по строкам: ENABLE + FORCE, политика с USING и WITH CHECK. DELETE не выдан никому — строка остаётся носителем надписи «удалены по сроку хранения»; - ДВА срока, а не один — решение владельца Р82: звук 30 дней, расшифровка шесть месяцев. Обе даты истечения NOT NULL: строка без даты означает голос, которого не найдёт ни одна чистка. Сами сроки — в настройках портала, не в коде; - два следа удаления врозь: команда чистки из З-2.2 обязана уметь стереть звук, не тронув текст. Два замка не дают строке врать «стёрто», пока файл жив; - закрытое хранилище obzvon_materialy: без ключа url и с serve=false, корень под storage/app/private. Публичной ссылки на чужой голос не появится даже по ошибке; - служба MaterialyKlientaService: приём с отклонением чужого формата и битого файла, открытие с явной сверкой клиента поверх защиты по строкам, счёт записей без нижней границы — три берём, двадцать берём, ноль пускаем вторым путём; - отказ MaterialNePrinyat отделяет наш сочинённый текст от дословного показания системы видимой границей. Настоящая причина не выбрасывается; - след в журнале ПДн pd_processing_log готовым сервисом PdAuditLogger — на приём и на каждое открытие. Стирает НЕ эта правка, а З-2.2, и она не построена. До её постройки материалы не исчезнут — это известно и так задумано. При накатке на боевой ОБЯЗАТЕЛЕН перезапуск db/03_service_bypass_policies.sql: новой RLS-таблице мало политики и прав, иначе служебная роль молча правит НОЛЬ строк. Тронут один чужой сторож: SchemaDeltaTest, счётчик таблиц канона модулей 42 в 43. Так же его правили соседи на З-1.1 и З-1.3 — счётчик обязан расти вместе с каноном, иначе прогон красный у всех. Осталось открытым: от какой даты считать полгода — считаем от загрузки, столбец recorded_at заведён под будущий ответ владельца. 24 сторожа, все 24 показаны красными двенадцатью ножами. Полный прогон 4954 проверки, 4950 зелёных, 4 пропущено, красных 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2f0ef8e868 |
feat,обзвон: тариф звонка переехал в базу — админ правит цену без программиста
Беда: правило цены звонка уже написано и работает, а самих двух цифр — за снятую трубку и за начатую минуту — держать было негде и менять некому. Чтобы поправить цену, нужен был программист и новая сборка. Заведена таблица obzvon_tariffs: строка ровно одна на весь портал, замок CHECK id = 1, деньги целыми копейками — те же единицы, что у строки звонка. Ручка админки GET и PUT /api/admin/obzvon/tariff правит обе цифры разом: половина новой пары с половиной старой давала бы тариф, которого никто не назначал. 🔴 Обе цифры назначены владельцем ВСЛЕПУЮ: сколько нам самим стоит звонок, ни разу не измерено. Оговорка написана в четырёх местах вплотную к самим числам и уходит в ответ ручки, чтобы её видел человек на экране, а не только программист в коде. 🔴 Смена тарифа НЕ пересчитывает вчерашние звонки: цена и снимок тарифа лежат в самой строке звонка. Здесь только «сколько будет стоить следующий». Мусор в цене не принимается: отрицательная, пустая, нечисловая и с третьим знаком после точки — третий знак молча пропал бы копейкой. Правка оставляет след в saas_admin_audit_log — кто, когда, с чего на что. Канон схемы — db/schema_modules.sql раздел 23, журнал — запись v9.69. Сторожа — app/tests/Feature/Obzvon/ObzvonTariffTest.php, все показаны красными. План: docs/superpowers/plans/2026-08-04-obzvon-pod-klienta.md §З-1.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
30ca789507 |
feat,обзвон: место под звонок — таблица попыток и единственный итог по номеру
Задача З-1.1 плана «Обзвон под клиента». До этой правки фичи в портале не было вовсе, и звонок было некуда положить: нечего списывать, нечего показать клиенту и нечем ответить через год на вопрос «за что вы взяли с меня деньги в августе». Заведены две таблицы, обе с защитой по клиентам ENABLE + FORCE: - obzvon_calls — одна попытка набора. По номеру их бывает до двенадцати, и каждая своя строка со своим исходом. Держит два плеча врозь: робот с человеком и человек с менеджером после перевода, у каждого своя длительность и свой признак «состоялось». Держит два срока хранения и два следа удаления: звук месяц, текст три месяца, дальше живут сухие итоги. - obzvon_number_results — единственный итог на всю работу с номером. Именно он стоит в карточке сделки, а не последняя попытка. Замок — UNIQUE по клиенту, кампании и номеру: двенадцать недозвонов дают ОДИН итог, а не двенадцать. Расшифровка лежит в той же строке звонка. Отдельного хранилища под неё нет намеренно — решение Р50 сняло бессрочную обезличенную расшифровку. Имя своё, а не call_recordings из закомментированного чертежа: чертёж был про телефонию вообще и не знает ни статуса обзвона, ни сроков со следом удаления, ни «почему звонили», ни двух плеч, ни попыток с итогом. Чертёж не тронут. Цена звонка запоминается в строке вместе со снимком тарифа, а не вычисляется из действующего тарифа при показе: иначе смена тарифа задним числом перепишет вчерашние счета. Образец — lead_charges.tier_no + price_per_lead_kopecks. Деньги целыми копейками. Строку звонка никто не удаляет: право DELETE не выдано ни одной роли. Вместе со строкой ушли бы деньги, а спросить «за что вы взяли» клиент вправе и через год. Канон — db/schema_modules.sql, новый раздел 23. Тело db/schema.sql таблиц не получает: оно исполняется первой миграцией, и таблицы дельта-миграций в него класть нельзя — это прямо запрещает сторож канона. В db/schema.sql добавлена только пометка над закомментированным чертежом call_recordings: чертёж не использован и не будет, модуль живёт в obzvon_*, и перечислено, чего чертёж не знает. Чтобы следующая смена не воскресила его по недоразумению. Журнал схемы — запись v9.68, номер свободен, столкновения нет. Проверено: 21 сторож, в том числе изоляция клиентов живым запросом из-под роли crm_app_user, а не глазами по миграции; служебная роль после перезапуска db/03_service_bypass_policies.sql правит именно ту строку, а не «успешно ноль»; откат миграции хвостов не оставляет; squawk с конфигом проекта чист; статанализ по новым файлам ноль. Каждый сторож показан красным. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fa4b9e83a4 | feat(воронка): колонка rubric у карточки — ниша фирмы | ||
|
|
f70527c3df |
fix,деньги: телеграм-реклама переведена на рекламный кошелёк с заморозкой — робот наконец платит МТС
🔴 Найдено чтением кода, а не по памяти: робот НИКОГДА не оплачивал кампанию. finalize доводил боевой запуск до кассы кабинета и уходил (launched:false, stoppedAt:'payment'), денежные кнопки ему были запрещены наглухо, а «реальная оплата» отложена на «Сессию 6», которой не случилось. Песочницу при этом выключили 02.08 в 07:40. Итог на бою: клиент жал «Запустить» → у него списывалась ВСЯ смета по размеру списка → в кабинете оставался неоплаченный черновик → на модерацию он не уходил → реклама не показывалась ни разу → деньги не возвращались никогда (статус draft_ready терминальный, возврата не имеет). То есть беда была не «клиент переплачивает разницу», как записали накануне, а «клиент платит сто процентов ни за что». Слепки установленного у владельца робота совпали с веткой до буквы — на боевой машине тот же код. Владелец решил: боевой не трогать (стоит как стоит), роботу денежную кнопку разрешить, но с потолком. ── Робот теперь платит ──────────────────────────────────────────────────────── submitWithPayment на шаге /payment: сперва ЧИТАЕТ сумму к оплате, потом гонит её через гейт против меньшего из двух потолков (лимит кампании и общий потолок робота), и только потом ищет кнопку и жмёт. Сумма не прочиталась — не платим: не знаем, что списываем. Не ушли со /payment после клика — падаем громко, портал по отказу отпустит заморозку. Общий чёрный список денежных кнопок НЕ ослаблен: та же кнопка остаётся запретной для всех прочих путей, включая пересдачу. Разрешение точечное. Прочитанная сумма — это НАШИ расходы у МТС; она едет в портал полем actualCostRub, которое до сих пор было пустой заготовкой. ── Кошелёк вместо общего баланса ────────────────────────────────────────────── Порядок зеркалит сам МТС (билинг снят живьём 27–28.07, FLOW-FINDINGS «Задача 2.0»: кабинет резервирует сумму, окончательно списывает по факту показов, остаток возвращает): запуск → морозим смету на ad_wallets (канал telegram) робот заплатил → фактическая сумма легла в кампанию (mts_cost_rub, v9.66) модерация «да» → списываем по факту × наценка, остаток отпускаем модерация «нет» → отпускаем всё, ни рубля не списано сбой до кабинета → отпускаем всё Заморозка была убрана 29.07 намеренно — тогда рассуждали «сумма известна в момент запуска, морозить нечего». Рассуждение верно ровно до вопроса владельца: сумма известна, а сколько человек из списка вообще есть в телеграме — нет. Факта нет, а модерация одобрила — НЕ списываем ничего и кричим в журнал. Списать «по оценке» значило бы вернуть ровно ту беду, ради которой всё и делалось. Идемпотентность больше не самодельная: бронь уникальна по кампании, списание — по ключу события. Прежнее «сальдо проводок» стало не нужно, CampaignChargeServiceTest удалён — его предмет (charge/refund по общему балансу) больше не существует, замена KoshelekTelegramaTest. ── Экран ────────────────────────────────────────────────────────────────────── Карточка денег показывала общий баланс портала. После переезда это стало прямым враньём: клиент видел бы «денег хватает» там, где запуск отвечает 409. Теперь ручка отдаёт СВОБОДНЫЕ деньги кошелька (баланс минус заморозка) и заморозку отдельной графой, а кнопка пополнения ведёт в рекламный кошелёк — прежняя клала бы деньги в общий баланс, и запустить рекламу всё равно было бы нельзя. ── Проверено вырезанием, а не только зелёным ───────────────────────────────── - убрал запрет «сумма не прочитана» — покраснели 2 датчика оплаты; - вернул списание по смете вместо факта — датчик поймал 315 ₽ там, где должно быть 210 ₽. Замеры: телеграм-модуль 330/330, вместе с рекламой и СМС 1078/1078, экраны 251 файл / 2033 теста / 0 падений, робот 160/160, статанализ 0, типы 0, формат чисто. Сторож денег под боевой ролью переписан: класс «тихий ноль» закрылся сам — AdWalletService ставит контекст клиента сам, а не надеется на вызывающего. 🔴 На боевой НЕ выкачено. Осталось открытым: показать клиенту строкой «заморожено / списано по факту / возвращено» на карточке кампании; пересдача по-прежнему шлёт «без оплаты» (кампания вернётся на модерацию неоплаченной); числа 0,720 и 0,816 за показ с медиа так и не замерены живьём. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b7ad9e0a66 |
merge: телеграм-реклама второй смены сведена в рабочую ветку перед выкатом СМС
Замер боевого перед выкатом СМС-модуля показал мину: на liderra.ru сейчас работает код ветки fix/tg-zagolovok-obyavleniya — 33 записи телеграм-рекламы, которых не было ни в main, ни в рабочей ветке. Сверено слепками файлов: config/client_tg.php и TelegramTariffService.php на бою совпадают с их версией и расходятся с нашей. Экраны портала собираются одним куском, поэтому выкат СМС в прежнем виде откатил бы их живую рекламу назад. Решение владельца — сперва забрать их работу к себе. Разрешено пять склеек, все — сохранением обеих сторон: - Tariff.php: их пояснение про закупочную цену плюс наша строка для подсказчика типов; - phpstan-baseline.neon: взята наша вычищенная версия. Их 260 строк заметания не возвращены — статанализ после сведения дал 0 без них, потому что чинили мы не baseline, а сам механизм, и он вылечил их новые тесты тоже; - advertising-telegram-view.spec.ts: наш типизированный мок оставлен; - CHANGELOG_schema.md: столкновения номеров НЕ было — у нас v9.33/v9.34, у них v9.64/v9.65. Обе пары сохранены, ничего не двигали; в файл дописано, что дыра v9.35–v9.63 это след их завышенного замера, а не потерянные записи; - STATUS.md: служебный файл наблюдателя, взят свежий. Что сведение вскрыло дополнительно: - их сторож значков поймал НАШИ три имени с экранов СМС — mdi-file-sign, mdi-account-badge-outline, mdi-file-download-outline в карте Lucide отсутствовали и на бою рисовались бы вопросом в кружке. Добавлены по смыслу; - проверка типов фронта: 1 ошибка стала 0. Образец имени отправителя в тесте админки не задавал два поля, и Partial подмешивал в них undefined. Проверено после сведения: PHP 4614 тестов, 4610 зелёных, 4 пропущено, 0 падений; экраны 250 файлов, 2018 зелёных, 3 пропущено, 0 падений; статанализ 0 через composer stan; проверка типов 0. Унаследованное, не мной внесённое и не тронутое: линтер фронта показывает 1 замечание на неразрывный пробел в advertising-sms-view.spec.ts — знак там нужен по смыслу проверки, было до сведения. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
36f5093692 |
merge: общая ветка с клиентским СМС-модулем сведена в рабочую
Общая ветка переведена вперёд на ветку клиентских СМС — перемоткой, без слияния, поэтому конфликтов там быть не могло. Затем общая сведена в рабочую ветку, которая отставала на 79 записей. Столкновение было одно и знакомое — словарь орфографии cspell-words.txt. Разрешено правилом «обе стороны настоящие»: слова обеих веток сохранены, ничего не выброшено. Журнал схемы БД на этот раз свёлся сам, столкновения номеров не было. СТОРОЖ ПДн ОСТАНОВИЛ ЗАПИСЬ И БЫЛ ПРАВ. В образце базы номеров, который клиент скачивает перед своей первой рассылкой, стоял рабочий телефон владельца — в двух видах. Это не утечка чужих данных: тот же номер публично опубликован в реквизитах ИП по требованию ЮKassa. Но клиент заполняет этот файл своими номерами и запускает рассылку — забытая строка означала бы СМС владельцу за деньги клиента. Заменён на выдуманный из тестового диапазона. Подсказки на экране рассылок («+7 999 123-45-67») выдуманы изначально; разрешены в .gitleaks.toml ПО ЗНАЧЕНИЮ, а не по файлу, чтобы сами файлы остались под охраной. Сторож проверен вырезанием: подложенный номер, не подпадающий ни под одно разрешение, он поймал; после снятия подложки — чисто. Служебный счётчик наблюдателя убран в тайник на время сведения и возвращён после — он машинный и пересоздаётся хуками. Файлы второй смены, которая работает в этой же папке, не тронуты и в слияние не попали. Проверки сведённой ветки: тесты 4533, зелёных 4529, падений нет; экраны 238 файлов и 1906 зелёных, сторож сети не сработал ни разу; статанализ 0; формат чист. |
||
|
|
3864f6f11a |
feat,телеграм-реклама: цена показа по виду медиа вместо ступеней по объёму
Мы продавали дешевле, чем покупали. Тариф давал скидку за объём — 0.45 → 0.36 ₽ за показ, — а у МТС такой скидки нет: прайс кабинета, стр. 13, берёт 0,48 ₽ за показ плоско. Скидку давали мы, а нам её не давал никто: на объёме от 50 000 наценка 1.40 превращалась в 5%. Второе. Кабинет показывает CPM БЕЗ НДС — колонка списка так и названа. Счёт кампании 2231134 сошёлся: 420 × 400 ₽/1000 × 1,2 = 201,60 ₽. Мы ИП на УСН, НДС не возмещается, это расход. Себестоимость с НДС: 0.480 без медиа, 0.720 с картинкой, 0.816 с видео. Третье. Вид медиа до расчёта не доходил вовсе — объявление с видео продавалось по цене объявления без картинки. На видео уходили в минус до 176 ₽ с тысячи, и портал нигде свою цену с ценой МТС не сравнивал. Песочница выключена с 02.08 — деньги живые. Что сделано: - миграция client_tg_cena_po_media: min_qty → media_kind, точность цены 6,2 → 6,3, три строки вместо пяти ступеней, media_kind на кампании и авто-правиле; - вид медиа определяется по СОДЕРЖИМОМУ файла, а не по расширению имени; - экран админки переделан: три фиксированные строки по видам медиа, рядом цена за тысячу для сверки с кабинетом, добавлять и удалять нечего; - запись v9.65 в журнале схемы. Замеры этой смены: телеграм-модуль 313 тестов 0 падений, экраны 242 файла 1841 тест 0 падений, статанализ 0, форматтер чисто. Проверка типов — 6 ошибок, все в чужих файлах, были до нас. Тест экрана тарифов проверен вырезанием, а не только зелёным: сломал передачу media_kind — красный; сломал расчёт цены за тысячу — красный. Осталось незакрытым: числа 0.720 и 0.816 — вывод по правилу «кабинет пишет без НДС», а не замер. Счёт кампании с картинкой живьём не снимали, шаг «Стоимость» недостижим без загрузки живых номеров. Замерите — правится одной строкой. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
929b0a744b |
fix(схема): задвоенный указатель на сделках сведён именем — на бою его не было
Задача звучала «снеси на боевой базе deals_deleted_at_index, оставь deals_deleted_at_idx». Замер боевой базы (pg_index на rw-endpoint кластера c9q2cvtjpq3hgq6l0r96) показал обратное: там указатель ОДИН, и зовут его как раз deals_deleted_at_index — второго на бою нет и не было. Исполнение задачи как записано снесло бы единственный указатель по deleted_at на самой горячей таблице портала. Боевая база не тронута ни одной изменяющей командой. Дубль жил в сборке, а не на бою: db/schema.sql заводил индекс БЕЗ имени (PostgreSQL звал его deals_deleted_at_idx), а миграция 2026_06_17_120000 — такой же с именем. schema.sql исполняется первой миграцией, поэтому каждая собранная с нуля база (местная dev, все тестовые) получала два, и любое изменение сделки писало в оба. Боевая собиралась иначе. Починка двумя половинами. В каноне имя задано явно и совпадает с боевым — миграция 2026_06_17_120000 идёт с IF NOT EXISTS и на собранной базе становится пустой. Новая миграция 2026_08_02_100000 убирает лишний deals_deleted_at_idx из уже собранных баз; на бою это пустая операция. Она сверяет не только имя, но и определение: _idx выдано PostgreSQL автоматически и в чужой базе могло достаться другому индексу. Приёмка вырезанием — порознь по половинам, потому что первая попытка была негодной: вырезал имя из канона, а сборка всё равно дала правильный ответ, её вылечила вторая половина. Замер «канон + миграция 17.06» без новой миграции: на сломанном каноне 2, на исправленном 1. Полная сборка с нуля 149/149 DONE — один указатель. В тестовой базе шесть половинок партиций прицеплены к выжившему, осиротевших ноль. Сверка канона с базой — все четыре счётчика ноль. Статанализ ноль и проверен красным: подложка назвала именно новый файл. Полный набор PHP после правки — 4122 теста, 4118 прошли, 4 пропущено, 0 падений. Журнал схемы v9.34, версия канона v8.87. В промт смене 9 вписано, что прежняя инструкция была вредной и исполнять её нельзя, плюс два правила: утверждение промта о боевом состоянии — гипотеза до замера, и вырезание доказывает что-то только если вырезаны все пути к результату. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7922bffb6d |
feat(телеграм-реклама): заголовок и картинка в авто-режиме + честная проверка медиа по требованиям МТС
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена 02.08.2026 — каждая дыра ниже стоила живых денег. 1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта; утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания. Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране. 2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране. 3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08. Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF, пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком маленькая: 300x200. Нужна не меньше 640x360». 4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки файла, на обоих экранах. Сверх плана, найдено по дороге: 5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком, в журнал пишется причина no_headline. 6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом. Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe. Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист. Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено. Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не повторить столкновения 29.07 и 01.08. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bffca85399 |
docs+fix: канон схемы догнал миграции — 39 таблиц описаны, сверка считает обе стороны
Канон знал 82 таблицы и 995 столбцов, а база — 121 и 1470: не описаны были рекламный модуль, телеграм-рассылки и весь отдел продаж. Главное, что выяснилось по дороге: db/schema.sql не документ, а исполняемый файл — миграция 0001_01_01_000000_load_initial_schema заливает его целиком первым шагом сборки. Перенести туда DDL модулей нельзя: те же таблицы создают дельта-миграции, их 77 и 49 из них без стражей. Сборка с нуля падала дважды. Поэтому канон разбит на два файла: schema.sql исполняется, новый schema_modules.sql только описывает. Проверено не глазами: - сборка с нуля 148 из 148 DONE; - столбцы 1470 против 1470, ноль расхождений по имени, типу, длине и NULL; - все четыре счётчика расхождений инструмента — ноль; - schema.sql отдельно исполняется без единой ошибки, ограничения 495=495, политики 64=64; - статанализ 0. Инструмент сверки усилен: считал только одну сторону и потому не видел переименования jivo_chat_id в chat_id — старое имя жило в каноне месяц. Теперь обе стороны, канон из нескольких файлов, понимание RENAME/DROP COLUMN и DROP TABLE. Починен разбор переносов строки внутри определения столбца. Сторож SchemaDeltaTest дополнен: файл модулей обязан быть на месте и описывать 39 таблиц, а тело schema.sql их не содержать. Принят вырезанием. Найдено и НЕ исправлено, нужно решение владельца: задвоенный указатель на deals и пять политик без NULLIF в миграциях. Оба помечены в тексте канона. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b6a15c5bbf |
merge: подтянул общую ветку в клиентские СМС — 227 записей отставания закрыты
Ветка шла отдельно почти неделю и отставала на 227 записей, отставание росло
каждый день. Направление сведения — общая В ветку: перевод main владелец
отклонил, значит вливать в него нечего.
Девять столкновений, каждое разобрано по существу.
Журнал схемы: столкнулись НЕ три номера, как ожидалось, а ВСЕ - обе ветки
независимо заняли v8.96-v9.25 и v9.32 разным содержимым. Обе стороны
настоящие, выбросить нельзя ни одну, поэтому перенумерована ветка, а не
общая: 31 запись уехала в свободный диапазон v9.33-v9.63. Содержание не
тронуто - доказано сверкой с исходной версией через git, посимвольно.
Соответствие старых номеров новым вписано в сам журнал, чтобы старые
документы ветки оставались читаемыми. Прежняя пометка про "запас v9.32"
заменена: запас не спас, v9.32 в общей ветке тоже был занят.
Сборка тестовой базы: взята версия общей ветки. Она позже и доказана
замером - двумя шагами вместо migrate:fresh, который спотыкался на
типе-призраке и оставлял схему неполной.
Список слов орфографии сведён объединением: 2106 наших + 2169 общих дали
2173, ни одно слово ни с одной стороны не потеряно - проверено сравнением.
Расписание работ, маршруты экранов и админский слой: обе стороны добавляли
своё в одно место, оставлены обе.
Витрина рекламных каналов: каждая ветка сделала настоящим СВОЙ канал -
ветка СМС свой, общая Телеграм. После сведения настоящих три, заглушки
исключают все три. Сторож витрины принят вырезанием: убрал СМС из списка
настоящих - покраснел, вернул - позеленел.
СТОЛКНОВЕНИЕ ИМЁН, созданное самим сведением. Оба набора тестов объявляли
глобального помощника pollCampaign - свой в СМС (один довод) и свой в
Телеграме (от двух до четырёх). Две функции с одним именем в одном языке
не живут: пока ветки шли врозь, этого не видел никто. Помощник СМС
переименован в pollSmsCampaign. Проверено, что других таких пар в PHP-тестах
нет ни одной.
Статанализ ветки доведён с 674 замечаний до НУЛЯ, уровень не понижен и в
baseline не заметено ничего.
- 616 из 674 - ложный класс Pest, закрытый тремя узкими правилами; правила
перенесены из рабочей ветки, где владелец их уже принял;
- остальные 42 - свои, в новом коде ветки, и починены по существу:
задвоенный ключ массива в трёх тестах (след копирования - комментарий
оторвался от своей строки), врущие описания двух помощников (PHP сам
делает из ключа-номера число), сужение типа возврата, прятавшее от
анализатора свойства подставного отправителя, лишний знак вопроса и
четыре бесполезных перенумерования списка.
- Приёмка вырезанием: подложил несуществующий метод - анализатор назвал его
поимённо и покраснел; убрал - ноль.
Шапки 59 моделей обновлены пересборкой подсказчика и ОСТАВЛЕНЫ намеренно
(правка только в комментариях, проверено): без них анализатор не связывает
модель с описанием и не знает, что дата - это дата, а не строка. Откатил их
сперва по привычке - получил 15 замечаний про даты, вернул - ноль.
Орфография: 15 файлов проверено, 0 замечаний (смотрел и на число
проверенных файлов, не только на число ошибок). Добавлены три слова из имён
миграций ветки.
Заодно: алиас ruflo-core в списке имён сторожа реестра - плагин описан в
реестре групповым именем, сторож видел только машинное. Мостится ТОЛЬКО имя;
🔴 содержательный долг остаётся - реестр до сих пор зовёт ruflo изолированным,
хотя его разморозили 28.07. Это чинить отдельно, через claude-md-management.
Проверено: три фронтовых сторожа рекламы 18/18, статанализ 0, разметка 0,
орфография 0, синтаксис PHP чист. Полный прогон тестов ветки - отдельным
шагом, он ещё ни разу не делался.
NB: в журнале схемы есть задвоенные номера v8.26 (пять раз) и v8.64 (два) -
это досталось по наследству из общей ветки, ровно столько же их там и было.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4efd3d3329 |
merge: подтянул воронку продаж с основной - иначе выкат откатил бы блок менеджеров
Владелец спросил, не потрём ли мы соседнюю смену. Проверил - потёрли бы. Наша ветка отстала от основной на 7 коммитов, и это ровно сегодняшняя работа по воронке продаж: корзина, журнал движений карточки, фильтр по датам, дробь у Отказа. Выкат по плану был ПОЛНЫЙ заливкой файлов - боевой сайт получил бы портал без всего этого. Соседи выкатывали точечно, шестью файлами, поэтому у них и не рвануло. СТОЛКНОВЕНИЕ НОМЕРОВ ЖУРНАЛА СХЕМЫ, ТРЕТИЙ РАЗ ЗА СУТКИ. Час назад я записал v9.31 за заголовок объявления. В это же время соседи записали v9.31 за корзину воронки и влили в основную. Обе записи настоящие. Git конфликта НЕ ПОКАЗАЛ - просто склеил две записи с одинаковым номером в один файл, молча. Наша подвинута на v9.32, боевая осталась на своём номере. Пересобран список исключений статанализа: после сведения он врал счётчиком 90 против 95 и не знал новых файлов соседей. Проверено, что пересборка ничего настоящего не спрятала - все 16 замечаний были одного вида, ложная тревога PendingCalls, плюс одно про форму данных внутри задания робота, тоже в тесте. ПОЧИНЕН ВРУЩИЙ СТОРОЖ ВИТРИНЫ РЕКЛАМНЫХ КАНАЛОВ. Тест утверждал, что настоящий экран есть только у Яндекса, а Телеграм получил свой ещё 27.07 - сторож пять дней держал фронтовый набор красным, и этого никто не видел, потому что фронт целиком не гоняли. Список настоящих каналов ведётся руками намеренно: смысл сторожа - поймать случайно прописанный маршрут у канала-заглушки. Приёмка сторожа вырезанием: на честном коде проходит, на подложенном маршруте для ВК падает, после возврата снова проходит. Файл каналов вернулся байт в байт. Замеры после сведения: - портал 4091 тест, 4087 прошло, 0 падений, 0 ошибок - фронт 232 файла, 1739 тестов, 0 падений - статанализ 0 замечаний - робот 130 из 130 На бой ничего не выкачено. Боевой сайт не тронут. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2fc844756c |
fix деньги: заморозка под боевой ролью больше не зависит от памяти человека
Находка приёмки Этапа 5. В Этапе 4 уже находили, что рабочей роли не выдано право на счётчик номеров таблицы заморозок ad_wallet_holds_id_seq, из-за чего заморозка денег под боевой ролью не проходила ВОВСЕ. Тогда стенд починили руками и переписали памятку выката. Приёмка проверила это на базе, собранной ТОЛЬКО миграциями — то есть на точной модели того, что получит боевой после выката. Права там снова нет: починка жила лишь в памятке. Ни одна миграция её не несла. Цена промаха денежная и широкая: заморозка делается при КАЖДОМ заказе рассылки, при заказе имени отправителя и при запуске рекламной кампании Яндекса. Выкат, на котором забудут перезапустить db/02_grants.sql, ломает всё это разом и молча. Что сделано: - миграция 2026_08_01_101400 выдаёт USAGE, SELECT на три счётчика кошелька рабочей и админской ролям. Роли не на глаз, а спрошены у базы: INSERT на сами таблицы кошелька есть ровно у этих двух, служебному работнику кошелёк не выдавался вовсе; - сторож tests/Feature/Advertising/AdWalletGrantsTest.php спрашивает у базы, а не читает текст миграции: опечатка в имени роли внутри миграции ошибки не даёт, права просто молча не выдаются; - запись v9.32 в журнале схемы. Структуру она не меняет — только права. Порядок работы соблюдён: сторож написан ДО миграции и покраснел ровно на том, на чём должен — «не выдано право на счётчик ad_wallet_holds_id_seq». После миграции зелёный. Живой прогон под ролью crm_app_user на базе из одних миграций: до — «нет доступа к последовательности», после — заморозка проходит и возвращает номер строки. Номер записи журнала взят v9.32, а не следующий свободный v9.26: номера v9.26-v9.31 уже заняты в других, ещё не сведённых ветках, и v9.26 повторил бы столкновение номеров, которое разбирали 01.08. Разрыв закроется при сведении с main. Памятку выката это НЕ отменяет: на уже накатанных базах миграция доносит право, но перезапуск db/02_grants.sql остаётся правильным шагом для всего остального. Статанализ добавил одно замечание — ложный класс Pest: анализатор не понимает $this внутри тестов Pest и не видит markTestSkipped. Тот же класс сидит в давно закоммиченном MigrationGrantsTest. Доказательство ложности прямое: тест проходит, не будь метода — упал бы. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a93cc12086 |
merge: свёл заголовок объявления с веткой робота - обе половины телеграма в одной ветке
Ветки разошлись: у соседней не было трёх правок тестов от 01.08, у нашей не
было заголовка объявления. Ни одна не содержала другую. Сведены в нашу.
Конфликт был ровно один и в автогенерённом файле docs/observer/STATUS.md -
время последнего обновления и список процессов. Взята наша сторона, хук всё
равно перегенерирует. В коде столкновений не было ни одного, в списке
исключений статанализа тоже.
Дописана запись журнала схемы v9.31 на их миграцию ad_headline - в исходном
коммите
|
||
|
|
1acbaf9383 |
Merge remote-tracking branch 'gitea/main' into feat/prospects-manual-testing-kp
# Conflicts: # docs/observer/STATUS.md |
||
|
|
9ee4e9a79f |
feat(воронка продаж): корзина, дробь у «Отказа», фильтр по датам — экраны
- 12-я колонка «Корзина», результат «В корзину» просит только причину; - в шапке «Отказа» дробь «4/2» с подсказкой «из них 2 после ручного тестирования»; при нуле дробь не рисуется — «69/0» это шум; - у «Выслано КП» поле даты подписано «если договорились» и необязательно; - орган фильтра по датам на ОБОИХ экранах, два режима + период. Приёмка глазами пройдена живьём по всем семи пунктам, включая «Воронку отдела» (снимки в docs/superpowers/screens/2026-08-01-korzina-filtry/priemka/). Браузер поймал то, чего не видели тесты: при смене режима оставался прежний период, и «что менялось за завтра» давало пустую доску — теперь период возвращается к «Сегодня», на это заведён отдельный тест. Прогон: сервер 1245/1245, фронт 1567/1567. Журнал схемы — v9.31. |
||
|
|
72db586a12 |
merge: свёл ветку телеграм-робота с основной, разрулил столкновение номеров журнала схемы
Основная ветка ушла вперёд на 23 коммита - приехала чужая работа по воронке продаж, вебхуку поставщика и сверке CSV. Конфликтов было два. 1. Журнал схемы db/CHANGELOG_schema.md. В обеих ветках лежала запись v9.28 от 31.07 про разное: у нас очередь заданий роботу, у них стадии воронки "Тестирование ручное" и "Выслано КП". Обе записи настоящие, поэтому ни одна не выброшена: боевая v9.28 осталась на своём номере, наши две подвинуты на v9.29 очередь заданий и v9.30 грант админ-роли. Поправлены ссылки на номер в шапках двух миграций, перекрёстные ссылки внутри самих записей и врезка "Перенумерация" вверху журнала - там теперь описан и этот случай. 2. docs/observer/STATUS.md - файл авто-генерируемый, взята версия основной ветки, хук перепишет его сам. Заголовок db/schema.sql не трогали: там своя нумерация v8.85, ни одна из веток её не двигала. Плюс одна настоящая находка статанализа, приехавшая с чужой работой: у метода SupplierPortalClient::fetchDeliveredLeads в описании возвращаемого набора не было поля tag, хотя код его уже возвращает и чужой же тест его ждёт. Дописал одно поле в описание, логику не трогал. Список исключений статанализа пересобран - разъехались счётчики ложного класса Pest от новых строк в чужих тестах. Проверено на ОТДЕЛЬНОЙ тестовой базе liderra_testing_tgmerge: телеграм на портале 244 из 244 робот 124 из 124 статанализ 0 замечаний код-стиль чисто полный прогон 4063 теста, 4018 прошло, 17 упало До сведения было 4039 тестов и 20 падений - тестов стало больше, падений меньше. Ни одно падение не касается телеграма или журнала схемы: 13 из 17 в рекламном модуле Яндекса, из них 11 - живой отказ авторизации Яндекс Директа, остальные счётные, от накопленных за прогон данных. Отдельно вскрылось при проверке: общая тестовая база liderra_testing испорчена - в ней 180 записей о применённых миграциях при 146 файлах, то есть в неё пишет не только эта рабочая папка. Из-за этого сборка базы срывалась на первом же шаге. Отдельная база всё вылечила. Это хвост Х2б, лечение в репозиторий не вносил - решение владельца. На бой ничего не выкачено, переключатель TG_ROBOT_TRANSPORT остаётся в process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d54ceed4ed |
docs(схема): запись v9.28 о стадиях Тестирование ручное и Выслано КП
В спеке §3 уточнено по факту кода: нормализатор отдаёт номер с плюсом, а хранится он без плюса — плюс срезаем. Сторожа орфографии и разметки пропущены: они не находят проблемы, а падают — их библиотеки в корне обрезаны той же поломкой, что статанализ и сторож журналов. Сторож секретов gitleaks — живой, работает. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c65856ec73 |
feat(смс-клиент): досыл не дошедшим — отдельной рассылкой «Досыл к рассылке №N»
Строка листа 5.6. В журнале рассылки появилась кнопка «Дослать не дошедшим (N)».
Нажатие спрашивает отдельным окном: сколько человек получат сообщение ещё раз и что
это отдельная рассылка с отдельной оплатой. По подтверждению заводится рассылка
«Досыл к рассылке №N» — у неё свои деньги и свой итог, а первая остаётся целой.
Почему отдельной рассылкой, а не второй попыткой в старой (решение владельца В-200):
переписав старые строки, мы стёрли бы «не доставлено», и человек больше не увидел бы,
что первая попытка не дошла. Да и база второй попытки не даст: на журнале сообщений
уникальный ключ по клиенту, рассылке и номеру.
Досылаем только по ОКОНЧАТЕЛЬНОМУ итогу (моё решение В-216): пока кто-то «ещё в пути»
или пока сама рассылка идёт — кнопки нет, а сервер отказывает человеческими словами.
Правило живёт в одном месте на сервере, экран получает готовый признак и условие не
пересчитывает: разойдись они — человек нажимал бы кнопку и получал отказ.
Деньги. Досыл списывается как обычная отправка (решение В-198) и своей денежной ветки
не имеет — считает, замораживает и списывает тот же общий код заказа. Повторное
нажатие второй рассылки НЕ заводит: ключ заказа постоянный, «resend:{номер}». Второй
клик означал бы вторую оплаченную рассылку тем же людям.
Живой прогон нашёл то, чего не видели тесты (В-227): досыл забывал оператора, которого
портал уже знал. Номер уходил как «вписанный руками», получал пометку «оператора не
знаем», а такую строку обогащение регионом уже не чинит — на бою, где универсального
канала может не быть, досыл не ушёл бы никому при созданной и оплаченной рассылке.
Теперь досыл вспоминает оператора из журнала сообщений первой рассылки: журнал хранится
без срока, в отличие от снимка получателей, который чистится через 90 дней.
Ещё две дыры, которых не было в плане. Первая: «в пути» считается только по
отправленному, поэтому у рассылки в работе он равен нулю — досыл разрешался бы посреди
работы (В-229). Вторая: защита от случайного повтора отбивала бы досыл молча, ведь текст
у него тот же, что у первой рассылки (В-228).
Схема v9.25: колонка resend_of_campaign_id и индекс у client_sms_campaigns. Прав не
требует — это колонка существующей таблицы, а не новая таблица.
Тесты: 8 на сервере, 4 на экране. Все проверены вырезом — двенадцать вырезов, каждый
покрасил именно свои тесты. Живой прогон в браузере: кнопка с числом, окно про отдельную
оплату, рассылка на два нужных номера с сохранённым оператором, первая рассылка цела,
второе нажатие ничего не завело. Стенд возвращён.
Движение денег живьём НЕ проверялось: на стенде включена песочница, а выключить её —
значит слать настоящие СМС за настоящие деньги.
|
||
|
|
17de8eb08a |
docs(схема): подтянул журнал схемы и словарь орфографии из main
Ветка отставала от main на 161 коммит; из файлов моей задачи устарел только журнал схемы. Беру канонический вариант из main, чтобы номер новой версии не столкнулся с занятыми (верхняя в main — v9.27). Словарь cspell подтянут тем же движением — без него сторож орфографии не пропускает свежие строки журнала. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
81d6de7588 |
feat телеграм-робот: приём отчёта роботом и общее применение итога
Применение итога вынуто из джоба в сервис без изменения логики — теперь его зовут оба пути, процессный и опросный. Отчёт принимается только по заданию в работе. Сторож принят вырезанием: без проверки статуса повторный отчёт проходит с 200. Отдельно закрыта мина, найденная ревью защиты и отсутствовавшая в плане: роль crm_admin_user имела на client_tg_campaigns только чтение, а канал робота пишет туда итог — на бою приём отчёта упал бы по правам. Миграция v9.29 даёт UPDATE. Полный набор тестов телеграма: 217 из 217 зелёные, старые в том числе. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
32c8fa2881 |
feat телеграм-робот: таблица заданий роботу с построчной защитой
Номера телефонов в задание не кладём — только текст, ссылка и смета. После выката на бой перезапустить db/03_service_bypass_policies.sql. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
36c8ced41e |
feat(смс-клиент): судьба сообщения отдельной графой + право служебной роли править журнал
Этап 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 чисто. |
||
|
|
97a93cb763 |
feat(смс-клиент): кнопка «Продолжить рассылку» — доводим до конца тех, на кого не хватило денег
Строка листа 4.13, решение владельца В-49 «надо обязательно». Рассылка встала из-за денег → клиент пополнил кошелёк → нажал «Продолжить», и она идёт дальше с того места, где остановилась. 🔴 Главная мина (В-151, подтверждена чтением кода): джоб отправки считает обработанным ЛЮБОЙ номер, у которого есть запись в журнале этой рассылки, без разбора статуса. А номер, на котором кончились деньги, записан как «не хватило денег». Значит простой повторный запуск молча пропустил бы его: человек заплатил бы за продолжение, а получатель не получил бы ничего. 🔴 И вторая находка, которой в плане не было (В-184): «оставить строку и просто не считать номер обработанным» НЕВОЗМОЖНО физически — на журнале сообщений уникальный ключ по (клиент, рассылка, номер), вторая запись не вставится. Поэтому продолжение эти строки УДАЛЯЕТ, и по смыслу это верно: «не отправили из-за денег» фиксирует НЕслучившееся и после пополнения перестаёт быть правдой. Заодно замерен масштаб: при остановке пишется РОВНО ОДНА такая строка, у остальных номеров записей нет вовсе. Что сделано: · POST /api/sms/campaigns/{id}/resume — убирает строки «не хватило денег», ставит рассылку в очередь, гасит причину остановки (иначе экран продолжал бы объяснять человеку прошлую остановку у работающей рассылки); · продолжаем ТОЛЬКО остановленную ИЗ-ЗА ДЕНЕГ (В-187). Остановленную человеком продолжать — значит отменить его решение; сорванную сторожем — наступить на ту же поломку. У каждой причины свой человеческий отказ, и текст под тестом; · правило живёт в ОДНОМ месте на сервере (как enableDecision в Task 4): список рассылок отдаёт готовый признак can_resume и число «сколько ещё уйдёт». Счёт остатка — в читателе снимка, где уже живёт «кто ждёт» (одно место, В-96); · кнопка «Продолжить» в таблице рассылок + подтверждение: сколько осталось отправить, что повторно никому не пойдёт, что цена прежняя; · цену НЕ пересчитываем, снимок НЕ пересобираем, заново НЕ замораживаем (В-39, В-188): списание идёт поштучно с проверкой перед каждым номером, в минус рассылка не уйдёт — кончатся деньги, встанет снова с той же причиной. 🔴 Мой же тест поймал расхождение с моим же решением (В-189): проверку денег я впихнул в правило «можно ли продолжить» — и кнопка исчезла бы именно у того, кому она нужна. Разделил: состояние — признак для экрана, деньги — проверка по нажатию с числами («свободно 0.00 ₽, одно сообщение 8.50 ₽»). Право DELETE на журнал сообщений рабочей роли — миграция 2026_08_01_101000, схема v9.22 (В-186, вторая половина мины В-152): без него продолжение на бою падало бы «нет доступа». Сторож MigrationGrantsTest расширен. Проверено: ClientSms 320/320 (13 пачек, все с первой попытки), приём лидов 17/17, фронт 1700 + 3 пропущенных (одна чужая давняя ошибка, В-57), phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Шесть вырезов, каждый покраснел ровно там, где вырезан. 🔴 Живой прогон ДЕНЕЖНЫЙ и парный под боевой ролью crm_app_user: рассылка на 5 номеров при деньгах на 2 встала (ушло 2, списано 17.00 ₽, одна строка «не хватило денег»); продолжение без пополнения — отказ с числами; БЕЗ пометки клиента рассылка не видна вовсе (изоляция держит); после пополнения с пометкой — ушло 5 из 5, списано 42.50 ₽, в журнале ровно 5 записей по одной на номер, «не хватило денег» не осталось. В браузере: кнопка видна глазами, после нажатия и прогона очереди рассылка «завершена, 5 из 5», кнопка исчезла, у нетронутой рассылки осталась. Заодно поправлена речь: было «отправим ещё 3 сообщений». Стенд возвращён и сверен; намеренное изменение одно — миграция накатана на локальную dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e29acb7af |
feat(смс-клиент): снимок получателей живёт 90 дней и чистится сам
Строка листа 4.12, решение владельца В-45. Снимок получателей — копия ПЕРСОНАЛЬНЫХ данных: список номеров рассылки с пометками, кому уйдёт и кому нет. Делается один раз при создании рассылки и больше не пересматривается (В-39), то есть после отправки лежит мёртвым грузом. Хранить его вечно нельзя. Что сделано: · команда `client-sms:purge-snapshots` в расписании раз в сутки в 03:30 (проверено schedule:list) уносит строки снимка старше 90 дней. Ходит служебным соединением — она кросс-клиентская и пометку клиента не ставит; · пачками по 5 000: снимок бывает на 20 000 строк, одним DELETE по дате это долгая блокировка. Прибор стоит именно на цикл — 5 001 строка обязана уйти целиком, иначе одна строка ПДн осталась бы жить вечно; · рассылка и её итоги НЕ трогаются: `client_sms_campaigns` и журнал сообщений остаются, как велел владелец. Цена этого названа вслух (В-178): через 90 дней уже нельзя ответить, кто из получателей ждал своего утра; · срок живёт в коде, в админке не правится (В-177): срок зависания рассылки — рабочая настройка, а 90 дней — обязательство про персональные данные, одно для всего портала. При ручном запуске срок передать можно, нулевой отклоняется человеческими словами — он снёс бы снимки живых рассылок; · про снимок НЕзакончившейся рассылки команда говорит вслух и в журнал сервера (В-176): в норме такого не бывает, и молчать об этом нельзя. Право `DELETE` служебной роли — миграция 2026_08_01_100900, схема v9.21 (В-152). Номер и версию взял по каталогу: названные планом были заняты Task 5 — третий раз этот класс (В-175). Сторож `MigrationGrantsTest` расширен и спрашивает саму базу, а не текст миграции. 🔴 Живой прогон под боевой ролью crm_supplier_worker УТОЧНИЛ мину В-152 (В-181). На стенде разрешающих политик srv_bypass нет вовсе (В-179), поэтому бой воспроизведён: политика поставлена тем же текстом, что в db/03_service_bypass_policies.sql, и после прогона убрана. Тройка: (1) политика есть, права нет — команда УПАЛА «нет доступа к таблице», строки целы (план предсказывал «удалит ноль и отрапортует успехом» — в жизни падение); (2) право есть, политики нет — «Удалено строк снимка: 0» с кодом УСПЕХА, вот настоящий тихий ноль; (3) обе опоры — удалено 3 из 4, свежая строка на месте, рассылка и её сообщение на месте, предупреждение о незаконченной рассылке прозвучало. Вывод для выката: без права беда ВИДНА (падение), без политики НЕ видна (успешный ноль). Проверено: ClientSms 315/315 (12 пачек, все с первой попытки), приём лидов 17/17, phpstan 2 чужие давние, pint чисто. Фронт не трогался вовсе — vitest и vue-tsc не гонялись, правок в экранах нет ни одной. Вырезов восемь, каждый покраснел ровно там, где вырезан; девятый — подмена служебного соединения на обычное — НЕ покраснел вообще (6/6 зелёных), и это честный результат: чем ходит команда, ловится только живым прогоном. Стенд возвращён и сверен со снимком ДО; намеренное изменение одно — миграция накатана на dev-базу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d292082ab |
feat(смс-клиент): потолок загрузки базы и пачечная запись вместо построчной
Строка листа 4.6 состояла из двух половин, и вторая («десятки тысяч
проходят и не падают») не выполнялась вовсе: каждый номер писался
отдельным запросом — 20 000 номеров стоили 25 278 запросов и 41 секунду.
Теперь пишем пачками по 1000 одним upsert: 23 запроса и 3.5 секунды.
Оплаченный ДаДатой оператор при повторной загрузке не стирается, дубли
внутри одной загрузки не роняют её, база не задваивается.
Потолок: колонка client_sms_settings.max_upload_phones (миграция
2026_08_01_100800, схема v9.20), по умолчанию 50 000, правится владельцем
в админке в границах 1 000…100 000. Сверх потолка загрузка отклоняется
целиком — частично загруженная база хуже незагруженной — и человек видит
оба числа. Экран говорит потолок ДО загрузки, числом с сервера.
Потолок спрашивается ПЕРЕД построчной проверкой номеров: иначе отказ на
50 001 номере занимал 20 секунд (замерено живым прогоном), а при верхней
границе запрос успел бы умереть по сроку жизни.
Ответ GET /api/sms/contacts стал объектом {items, max_upload_phones};
мёртвое поле contacts из ответа загрузки убрано.
Проверено: 14 серверных тестов, 2 фронтовых, 8 вырезов (каждый покраснел
там, где вырезан), живой прогон под боевой ролью crm_app_user и в браузере.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
77f61fb1d1 |
merge: сведение ветки «Реклама Телеграм» с боевым main
Слияние feat/client-telegram-ads с main
|
||
|
|
65e270583c |
feat(смс-клиент): шлём только абонентам МТС, Билайна, Мегафона и Теле2
Строка листа 4.14, решение владельца В-149 (вариант Б). Номер мелкого или виртуального оператора в рассылку не берётся, и человек видит честную причину, а не молчаливую пропажу. Каналы отправки НЕ тронуты: МТС возит своих, остальных троих — универсальный канал СМС-центра. Ограничиваем, КОГО берём, а не КЕМ везём. Список — настройкой, а не в коде: client_sms_settings.allowed_operators (схема v9.19), галочки «Кому шлём» в админке, пусто = четвёрка по умолчанию. Правило живёт в одном месте — AllowedSmsOperators. Решение принимается ДВАЖДЫ, и второй раз — единственная возможность: у сделок и своей базы оператор известен в момент заказа, у номеров, вписанных руками, его нет вовсе, и приговор выносится в момент ответа ДаДаты — в снимок ложится уже канонический ключ, где «Тинькофф Мобайл» неотличим от «ещё не спрашивали». Плата за имя не тронута (В-150): в коде два похожих списка операторов, и связать их значило бы поднять плату всем клиентам с 2500 до 10 000 рублей. Заодно починена давняя неправда на экране (В-154): «номер не из МТС (пока шлём только по МТС)» — универсальный канал возит всех. Доказательства: 8 новых тестов (в т.ч. сторож длины слага причины — колонка 24 знака), 6 вырезов, живой прогон с выключенной песочницей и пара на момент ответа ДаДаты, живой прогон в браузере со снятием галочки «Билайн». ClientSms 281/281, приём лидов 17/17, фронт 1685, phpstan 2 чужие давние, vue-tsc 5 чужих давних, pint чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f25c2f550d |
feat(смс-клиент): имя, отключённое за долг, возвращается само — за один месяц вперёд
Строка листа 4.4, Этап 4 Task 3. Раньше ночной работник смотрел только на работающие имена, поэтому имя, отключённое за долг, не возвращалось само никогда и ни при каких деньгах. Теперь у него есть второй проход: как только у клиента хватило СВОБОДНЫХ денег (с учётом замороженных под рассылки), имя включается, а плата берётся за один месяц вперёд от сегодняшнего дня. Старый долг прощается — решение владельца В-133: за месяцы, когда имя не работало, брать не за что. Документы заново не запрашиваются: заявка не пересоздаётся, меняются только состояние и срок оплаты. Сумма нигде не зашита — берётся из карточки имени. Она уже считается как «цена за оператора × число операторов», и когда подключим остальных операторов, вырастет сама. Плана оказалось мало (журнал В-140). Он предлагал возвращать всё, что «отключено и с долгом», но имя отключает и владелец руками из админки — и такое имя погашено у МТС, оно не работает. Портал включал бы его обратно и списывал за это деньги клиента, отменяя решение владельца. Отличать по тексту записки нельзя: текст — не признак. Поэтому у имени появилась графа «почему отключено» (за долг / рукой владельца); сам портал возвращает только первое. Схема v9.18, миграция 2026_08_01_100600, прав не требует. Второй правкой плана (В-141) переписан его тест на двойное списание: он проходил вхолостую, потому что после первого прогона имя уже работает и второй проход его не видит. Настоящая опасность — кнопка клиента (строка 4.5) и работник спишут за один месяц дважды, если ключ у них разный. Тест теперь про это и краснеет от порчи ключа. Проверено вырезанием, четыре выреза, все вернуты: — убрал проверку денег → покраснели четыре теста; — убрал отбор по причине отключения → покраснел тест про имя владельца; — испортил ключ месяца → списание прошло дважды, тест покраснел; — убрал пометку причины из админки → покраснел тест админки. Живой прогон под боевой ролью crm_app_user, пара «не произошло / произошло»: на счету 50 ₽ при плате 100 — имя осталось отключённым, деньги не тронуты; пополнил до 550 ₽ — имя работает, списано ровно 100 ₽, оплачено до 29.08, на счету 450 ₽. В том же прогоне имя, выключенное владельцем, при 5000 ₽ на счету осталось выключенным. Вторая пара — на пометку клиента: без неё имя не включается вовсе, и работник в обоих случаях молчит. Прогон вскрыл чужую мину (В-142): у боевой роли не было права на счётчик номеров таблицы движений рекламного кошелька — под этой ролью молча не проходило ни одно списание. Это расхождение локального стенда с эталоном db/02_grants.sql, кода не касается; в памятку на выкат вписана читающая проверка этого права на бою. Прогоны: клиентские СМС 262/262 (11 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. |
||
|
|
c0a8da37d2 |
feat(смс-клиент): зависшая рассылка сама срывается и возвращает замороженные деньги
Строки листа 4.1 и 4.2, Этап 4 Task 1. Беда, от которой сторожим: работник очереди умирает посреди отправки (перезапуск сервера, обрыв связи). Снятие заморозки денег стоит ПОСЛЕДНИМ шагом джоба отправки — до него он в этом случае не доходит. Итог на бою: рассылка вечно «идёт», деньги клиента заморожены навсегда, в журнале тишина. Команда client-sms:watch-stuck, каждые 15 минут. Движение меряется временем последней записи в журнале рассылки: sent_count для этого не годится, он проставляется только в самом конце. Порядок — решение владельца В-132: сперва ОДНА попытка дожать (это безопасно, джоб пропускает уже отправленные номера по ключу на номер), и только если и после неё не сдвинулась — срываем, размораживаем остаток, ставим причину «сторож». Итог считаем из журнала. Честно ждущая утра рассылка не трогается вовсе (строка 4.2): отличаем по состоянию — ждущая waiting_window, зависшая sending. Ждущих дожимает client-sms:resume-waiting. Миграция 2026_08_01_100500 — две колонки: срок «зависла» в настройках (правит владелец, строка 4.3) и отметка попытки дожать у рассылки. Прав не требуют, наследуют привилегии таблиц; повторный накат переживают. Схема v9.17. Проверено вырезанием, три выреза, все вернуты: — убрал условие про состояние → покраснел тест 4.2, ждущую положили в очередь; — убрал ветку «сперва дожать» → покраснел тест «кладётся в очередь ещё раз»; — убрал пометку клиента → живой прогон под боевой ролью показал молчаливый сбой. Живой прогон ПОД БОЕВОЙ РОЛЬЮ crm_app_user, парно: с пометкой клиента — рассылка сорвана, заморозка 17.00 → 0.00; без пометки — осталась «идёт», 17.00 зависли, и в ОБОИХ случаях команда сказала «Сорвано: 1» и вернула успех. Прогоны: ClientSms 249/249 (было 241, +8; 11 пачек, все с первой попытки), приём лидов 17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался. |
||
|
|
d242819e27 |
fix(смс-клиент): три блокера выката — снимок читался без пометки клиента, правка снимка и базы была без прав
Продолжение приёмки Этапа 3 по указанию владельца: «проверь замечание — так это или нет — и посмотри всё окружение на этот класс ошибок». Замечание оказалось верным, и рядом с ним нашлось ещё два блокера того же семейства. Ни один из трёх не виден ни одному тесту. 🔴 В-125. Джоб отправки читал снимок получателей ВНЕ пометки клиента: строки 115 и 139 стояли голыми в handle(), а обёртка открывалась только внутри цикла. Чтение снимка ленивое — запрос уходит не тогда, когда читателя позвали, а когда забирают очередную пачку, то есть уже за пределами чужой транзакции, а SET LOCAL живёт только до её конца. Замер у самой базы на ОДНОЙ строке снимка: суперпользователь (так идут все наши тесты) — 1 строка, боевая роль crm_app_user без пометки — 0, она же с пометкой — 1. Без ошибки, молча. На бою: рассылка закрывается «готово, отправлено 0»; а если в снимке есть ждущие своего утра — статус «ждёт утра», заморозка НЕ снимается, команда добора будит рассылку каждые 15 минут, та снова читает ноль, и деньги висят замороженными без конца. Починка стоит в самом читателе, а не у зовущего: он оборачивает КАЖДЫЙ свой запрос сам. Полагаться на внимательность каждого, кто его позовёт, оказалось нельзя — ровно на этом дыра и выросла. Зовущим не мешает: под запросом человека пометка уже стоит, вложенная транзакция ставит то же значение. 🔴 В-126 и В-127. Этап 3 научил двух помощников ПРАВИТЬ снимок и клиентскую базу — проставлять найденный у ДаДаты регион, пояс и оператора. А обе таблицы заводились под путь «строки только вставляют и удаляют»: прав на правку им не выдавали. Замер: UPDATE под боевой ролью — «нет доступа к таблице», SELECT той же ролью работает. На бою: номер, у которого пояс не был известен сразу, не получил бы его НИКОГДА, а номер без пояса не отправляется вовсе (решение владельца В-85). Для канала «своя база» пояс не известен ни у одного номера, пока помощник его не проставит, — то есть этот канал не отправил бы ни одного сообщения. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.16. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, и обещание опять оказалось неполным — как в В-122 неделей раньше. ⚠️ В-128, не чиню, называю. В трёх докблоках записано, будто служебная роль обходит изоляцию. ПИЛОТ.md от 07.07: на боевом кластере её не обходит НИ ОДНА роль. Значит служебное соединение живо не обходом, а политиками srv_bypass, и перезапуск db/03_service_bypass_policies.sql при выкате — не подстраховка, а несущая опора. Правка текстов — уровень всего приложения, не этапа. Доказательства. · Тест В-125 проверяет не результат (его подделать нельзя — суперпользователь всё видит), а ПОРЯДОК: каждый запрос к снимку обязан идти внутри той же открытой транзакции, где уже выставлена пометка. Уровень вложенности отличает «пометка здесь и сейчас» от «стояла раньше, в другой, уже закрытой». Красный до починки показал 5 чтений, все без пометки. · Живой прогон НАСТОЯЩЕГО джоба под боевой ролью (SET ROLE crm_app_user), парно: с починкой — «отправлено 2 из 2, статус готово»; без починки — «отправлено 0 из 2, статус готово, джоб не упал». Тот самый молчаливый сбой, вживую. · Права: живой UPDATE под боевой ролью — до миграции «нет доступа» по обеим таблицам, после миграции обе правки проходят. Данные пробы откатаны. · Вырезами трижды: убрал обёртку у чтения снимка — тест краснеет; опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; выдал права только одной таблице из двух — краснеет на второй. Всё возвращено. Обход всего окружения на этот же класс: 35 фоновых помощников и 36 команд классифицированы по тому, ставят ли они пометку клиента и каким соединением ходят. Те, что работают без пометки, трогают только таблицы БЕЗ изоляции (портал продаж, бот, внешние балансы). Отдельно искал именно ловушку «ленивое чтение уезжает из чужой обёртки» — в рекламном модуле и сборщике аудитории всё внутри. Права на автономера у всех новых таблиц выданы обеим ролям, по кошельку перекосов нет. Не проверял вглубь маршруты портала (их закрывает общая прослойка) и модули вне рекламы/СМС. Прогоны: СМС 241/241 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных (не трогал), phpstan 2 чужие давние, pint чисто. Стенд возвращён: dev-база — те же 12 клиентов и нули по модулю, пробные строки в тестовой базе откатаны. 🔴 При выкате ветки порядок прежний и обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Права и изоляция — разные механизмы, эта миграция того шага не заменяет. |
||
|
|
6704054a80 |
fix(смс-клиент): приёмка Этапа 3 — служебная роль получила права, экран перестал врать
Этап 3 «Время и цена», Task 8 — приёмка. Строка листа Н.2 (проверка разграничения доступа), итоговая таблица этапа заполнена. 🔴 Блокер выката, найден проверяющим по доступу и подтверждён лично. Команда добора `client-sms:resume-waiting` (появилась в этом этапе, стоит в расписании каждые 15 минут) ходит в базу под служебной ролью `crm_supplier_worker`: она обходит рассылки ВСЕХ клиентов и потому не может работать под клиентской ролью. Прав этой роли на три таблицы, которые она читает и правит, выдано не было — миграции модуля выдавали права рабочей роли, а служебной только на имя отправителя и правило авто-СМС. Локально этого не видно: dev и тесты ходят суперпользователем. На бою команда падала бы с «нет доступа» каждые 15 минут, и камчатская часть рассылок не дошлалась бы НИКОГДА. Тот же класс, что блокер прав на счётчики 27.07. Добавочная миграция (прежние прод не перезапускает), гард на существование роли, запись схемы v9.15. Сторож прав расширен: он спрашивал базу только про то, что модуль обещал, а обещание было неполным — теперь спрашивает и про эти три таблицы. Две неправды на экране, найденные живым прогоном на НЕудобных данных. · Текст в 2325 символов: сервер отказал (потолок 1000), сметы нет, а строчка под текстом пишет «Умещается в 1 СМС за номер». В коде стояло `preview?.segments ?? 1` — на месте «не знаю» подставлялась единица и утверждалась как факт. Теперь без ответа сервера число СМС не называется вовсе: молчание честнее. Дыра не этого этапа (код от 26.07). · Сам отказ был написан по-программистски: «Количество символов в поле body не может превышать 1000». Стало: «Текст длиннее 1000 символов — сократите его.» Вырезами проверено четыре раза: опечатка в имени роли ВНУТРИ гарда (та самая, что ошибки не даёт и права молча не выдаёт) — сторож краснеет; опечатка в цели GRANT — краснеет; убрал человеческий текст отказа — краснеет; вернул подстановку единицы — краснеет. Всё возвращено. Живой прогон строк 3.1–3.15 подряд (29.07, 06:45–07:00 МСК, песочница, локальная база): камчатский номер ушёл сразу, московский стал ждать 10:00, без региона — ждёт уточнения; экран сказал «Отправлено 1 из 3, 1 ждут утра в своих регионах, ещё 1 — уточняем регион»; команда добора до утра дала 0, после наступления срока 1 и номер ушёл, повторный запуск снова 0; ДаДата (ЗАГЛУШКА, боевой ключ не трогали) спрошена ровно один раз — про тот номер, у которого пояса не было; авто-СМС на московский лид отложена ровно на 10:00, при открытом окне три запуска дали одно сообщение; цена прошла 9.00 → 8.50 → 8.00 по накоплению, у отправленной рассылки осталась 9.00; счётчик сложил 500 из рассылки и 500 из авто-СМС; песочные отправки в счётчик не пошли. Стенд возвращён ровно в исходное: окно 10–20, 0 контактов, 0 сообщений, 0 рассылок, 0 снимков, 5 демо-сделок, кошелёк 1000.00 / заморожено 0.00. Прогоны: СМС 240/240 (11 пачек, все с первой попытки), приём лидов 17/17, фронт 1676 зелёных + 3 пропущенных, phpstan 2 чужие давние, vue-tsc 8 чужих давних (проверено git blame), pint чисто. 🪤 Урок приёмки: мой собственный скрипт прогона отрапортовал «красных пачек нет» и НОЛЬ зелёных — разбирал ответ не в том формате. Ноль почти всегда сбой, а не правда о мире. Теперь скрипт считает зелёным только ответ, где прошедших БОЛЬШЕ НУЛЯ. 🔴 При выкате ветки порядок обязателен: миграции → db/03_service_bypass_policies.sql → контрольный подсчёт политик srv_bypass (должно стать на 8 больше). Эта миграция его НЕ заменяет: без srv_bypass команда добора увидит тихий ноль даже с выданными правами. |
||
|
|
6f469fb13d |
feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления
Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.
Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».
Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.
Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
026e71a283 |
feat(смс-клиент): цена по накоплению за месяц, а не по объёму одного заказа
Этап 3 «Время и цена», Task 6. Строки листа 3.8, 3.9, 3.10, 3.14. Клиент, разбивший месяц на десять рассылок по сто номеров, платил по самой дорогой ступени, хотя отправил тысячу. Авто-СМС и вовсе всегда считалась по самой дорогой: в ступень уезжало число сегментов одного сообщения. Теперь ступень берётся для «сколько отправлено в этом месяце плюс объём самого заказа», и спрашивают об этом все три места сразу — предпросмотр, создание рассылки и авто-СМС на новый лид. Счётчик живёт в одном месте (ClientSmsVolumeCounter): его зовёт цена, а в Task 7 позовёт экран. Отдельного накопительного счётчика в базе не завожу намеренно — счётчик, разъехавшийся с журналом, опаснее лишнего запроса; журнал правдив, потому что деньги списываются той же записью. Считаем в СМС, а не в сообщениях (В-108). Длинное письмо — это два СМС, и платит клиент за два; считая строки журнала, мы держали бы его на дорогой ступени дольше обещанного, а цифра на экране «в этом месяце отправлено N СМС» разошлась бы со списанными деньгами. Ради этого в журнале появилась колонка segments (схема v9.14) и частичный индекс под единственный запрос счётчика. Пусто у старых записей = одно СМС (В-109). Граница месяца — по Москве, а не по Гринвичу (В-110): 31 июля 21:30 UTC это уже 1 августа в Москве. Не путать с окном 10–20 — там время местное у получателя, здесь московское у клиента. Цена по-прежнему фиксируется в момент создания и джобом не пересчитывается (3.14). 🔴 Мина, найденная самопроверкой (В-114): авто-СМС считала бы объём месяца ВНЕ изоляции по клиенту. В запросе экрана контекст ставит middleware, а в очереди — никто, и на бою счётчик вернул бы честный ноль: клиента молча посчитали бы по самой дорогой ступени, без единой ошибки в журнале. Тестами не ловится — на стенде изоляция не применяется. Счёт переехал внутрь tenant-транзакции, как и деньги в том же джобе. 🧹 Убран прежний estimateRub (В-113): он считал смету по ступени для объёма одного заказа, без накопленного, и больше не звался. Оставленный «на всякий случай» второй расчёт цены — это место, которое однажды позовут, и цифры разъедутся. Прогоны: СМС 229/229 (пачками по 3–4 файла — целиком локальная база уже не тянет, В-112), приём лидов 17/17, фронт 1663 зелёных, phpstan 0 своих, pint чисто. Вырезанием проверено четырежды: вернул старый расчёт в контроллер — покраснел тест через настоящий запрос экрана; убрал запись числа СМС в журнал — покраснел тест отправки; засчитал песочные — счётчик дал 12 вместо 1; перенёс границу месяца на Гринвич — покраснел тест границы. Живьём на локальной базе (20:17 МСК, песочница, ДаДата заглушена): предпросмотр до накопления 9.00 ₽, после 5 000 отправленных — 8.00 ₽; песочная рассылка ушла, в журнале «СМС=1», счётчик месяца остался нулём; у прежней рассылки цена так и осталась 9.00 ₽, а новый заказ уже шёл бы по 8.00 ₽. Стенд возвращён как был. Реальное списание по накопленной ступени доказано тестом, а не живьём: в песочнице деньги не двигаются (В-81). 🪤 Урок В-111: джоб авто-СМС глотает любой сбой и молча выходит — «ноль без причины» в тестах надо смотреть в журнале сервера, там лежала точная строка про мою описку. |
||
|
|
3fdcd88ad3 |
feat реклама за показы: правда про документ, экран «ждёт разбора» и разбор своих ошибок
Задача 16 закрыта решением владельца. Дороги «робот везёт документ в кабинет Яндекса» не существует — доказано двумя нарочными отказами, обычной тематикой и лицензируемой: в окне отказа ноль полей для файла, документы Яндекс принимает только снаружи кабинета. Приём документа оставлен, но портал больше не молчит: клиент приложил файл — в ленту сразу ложится отметка «документ у нас, передать его Яндексу автоматически нельзя, при необходимости отнесём сами и напишем здесь», а владельцу уходит письмо на адрес алертов. Без письма обещание было бы пустым: файл просто лёг бы на диск. Сам файл письмом не отправляем — это чужие бумаги. Обычный ответ без файла ни отметки, ни письма не даёт. Экран «ждёт разбора» в админке: ручка была, экрана не было. Третья карточка на странице «Реклама» — клиент, кампания, что робот делал человеческими словами, номер объявления и на чём споткнулся. В подписи прямо сказано, чего там НЕ будет: обычных отказов, их клиент разбирает сам. Дальше — разбор собственной работы этого дня. Найдено четыре ошибки, все исправлены. 1. ТЯЖЁЛАЯ. Доклад разведки на бою уронил бы очередь заданий целиком. Робот пишет в ленту под служебной ролью, а у неё на этой таблице было только чтение. Отказ по правам, 500 роботу, три повтора — и задание навсегда «в работе». Пока хоть одно задание в работе, выдача отвечает «работы нет» ВСЕМ клиентам. Лечение — запись схемы v9.17: право на запись плюс нумератор. В плане про это было написано прямым текстом, я прошёл мимо. Тесты поймать не могли: ходят суперпользователем. 2. Признак «набор создан» я выдумал: взял метку, которая в нашей же разметке описана как СКРЫТАЯ галочка. Проверка «видно ли её» не сработала бы никогда. Признак с экрана убран совсем: успех определяет портал слепком креативов, а «окно не закрылось» — это норма, так и есть живьём. 3. Сломал ленту для повторного отказа. Поменял защиту от дублей на «такой текст уже когда-либо был» — и клиент, починивший рекламу и получивший тот же отказ, не увидел бы ничего. Вернул сравнение с последним сообщением, а заглушку «причину выясняем» держит теперь сам джоб: показываем один раз, пока сказать нечего. 4. Мой собственный тест оказался пустышкой: оставался зелёным при вырезанной защите. В нём отклонялись ВСЕ объявления, а тогда кампания уходит в «отклонена» и обход её больше не берёт. Сценарий существует только при частичном отказе — тест переписан на два объявления и теперь вырезание защиты его роняет. Портал 391/391, админские экраны 9/9, фронт на затронутых наборах 52/52, робот 74/74, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен, живьём разведка не гонялась. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0ba7890bf9 |
feat реклама за показы: робот-разведчик приносит клиенту причину отказа с экрана кабинета
Задача 15. Программный интерфейс Яндекса причину отказа не отдаёт — на отклонённое объявление приходит «Отклонено на модерации.» и всё. Причина висит только на экране кабинета, и добыть её может лишь тот, у кого есть глаза. Как теперь работает: опрос модерации видит отказ и ставит роботу задание разведки по этому объявлению; робот открывает список объявлений, находит ячейку своего объявления, кликает по статусу, раскрывает строку причины, читает текст и снимает одно окно; доклад уезжает порталу формой вместе со снимком; портал кладёт его в ленту от имени Яндекса слово в слово, клиенту письмо и колокольчик. Робот не понял, что видит — задание сбойное, владельцу письмо, в ленту клиенту ничего не сочиняем. В админке появилась ручка «ждёт разбора». Четыре ловушки, пойманные по дороге и проверенные вырезанием: 1. Дедуп разведки нельзя вешать на кампанию. Отказ никуда не девается, а обход бежит по расписанию: после закрытия первой разведки поставилась бы вторая, и робот ходил бы в кабинет по кругу. Ключ — номер объявления, журнал схемы v9.16. 2. Рубильник держал не выдачу задания, а построение клиента Директа. Разведке слепок креативов не нужен, значит при выключенном рубильнике она получила бы задание, и робот пошёл бы в живой кабинет. 3. Дедуп ленты сравнивал только с последним сообщением Яндекса. После доклада робота обход снова клал бы «причину выясняем» поверх настоящей причины. 4. Постановка разведки шла без tenant-контекста — на бою она не сработала бы ВООБЩЕ и молча: поиск дубля давал бы ноль, запись падала бы на политике доступа, всё это в предупреждение журнала при зелёных тестах. Поймал rls-reviewer. Лечение — своя транзакция с выставлением клиента, рецепт ChargeCampaignSpendJob. Сторож поставлен на сам механизм: обычным тестом это не ловится, они ходят суперпользователем. Заодно: разведке больше не снимается слепок креативов — лишний поход в живой Яндекс внутри открытой транзакции. Портал 382/382, робот 75/75, мест снятия заморозки денег по-прежнему четыре. На боевой не выкатывалось, рубильник Директа выключен. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1d8723f285 |
feat(смс-клиент): Калининград и Камчатка получают СМС каждый в своё утро
Этап 3 «Время и цена», Task 2. Строки листа 3.1, 3.2 и первая половина 3.3.
У каждой строки снимка получателей появились две вещи: часовой пояс человека и момент,
раньше которого сообщение отдавать нельзя. Считается это ОДИН раз, при создании рассылки:
снимок сильнее всего (В-39), а на 20 000 номерах пересчёт на каждом витке джоба был бы
20 000 лишних расчётов.
Три состояния строки, и их важно не путать:
— пояс известен, ждать нечего → отдаём прямо сейчас;
— пояс известен, время не пришло → ждёт своего утра;
— пояса нет → ждёт уточнения региона и НЕ уходит вовсе (В-85).
Пустой пояс больше не означает «шлём по Москве». Он означает «мы не знаем, где человек
живёт», и такому номеру СМС не уходит. Про него при этом НЕ пишется в журнал рассылки
«нет маршрута»: мы его даже не пробовали отправить, и врать про него нельзя.
Рассылка, у которой часть номеров ещё ждёт, получает статус «ждёт утра» вместо «готово»,
и заморозка денег с неё не снимается — смета считалась на всех, оставшимся деньги ещё
понадобятся. Счётчик отправленного при этом обновляется: он считается из журнала, то есть
всегда правда, и человеку нужно видеть «отправлено 340 из 900» сразу, а не завтра (В-94).
Регион сделки читается из subject_code, а НЕ из region_code: последний в бою не пишет никто,
а в dev там демо-значения, и мы бы считали половину страны Тюменью — молча (В-82).
Прогоны: СМС 191/191 (было 186, 5 новых тестов), приём лидов 17/17, phpstan 0, pint чисто.
Вырезанием проверено дважды: убрал проверку «пояс известен» — покраснел тест про номер без
региона; убрал проверку «время пришло» — покраснели три теста про окно. Живьём на локальной
базе: две сделки (Москва и Камчатка) плюс пять демо-сделок без региона → ушёл один москвич,
Камчатке проставлено ожидание до 22:00 UTC (10 утра её времени), пятеро ждут региона,
рассылка висит «ждёт утра». Стенд возвращён как был.
В-93: у 19 старых тестов покраснение было закономерным — у их номеров нет региона. Боевой код
не ослаблял: фикстурам проставил регион и зафиксировал время прогона, иначе тесты зависели бы
от часа запуска. ⚠️ Ветку нельзя выкатывать между этой задачей и Task 5: пока ДаДата не начнёт
давать регион всем номерам, рассылка по своей базе и по списку руками отправит ноль.
Запись схемы v9.13.
|
||
|
|
86d974330b |
feat(смс-клиент): правило «с 10 до 20 по местному» живёт в одном месте, границы правит владелец
Этап 3 «Время и цена», Task 1. Закрыты строки листа Н.3 и 3.7. Поведение рассылки ещё НЕ меняется — этим займётся Task 2. Сейчас заведено то, на чём оно будет стоять, и заведено так, чтобы правило нельзя было размножить. 1. Справочник часовых поясов (RegionTimezoneMap). 89 субъектов РФ в том же порядке, что и справочник имён; сторож-тест сверяет составы, чтобы справочники не разъехались. Отдельный тест на ловушку: код субъекта у нас НЕ автомобильный — 77 это Тюменская область, а Москва 82. Неизвестный код и непонятная строка от ДаДаты дают «не знаю», а не ноль: ноль означал бы Гринвич, то есть тихую подмену Камчатки Лондоном. 2. Единственный дом правила 10–20 (SmsQuietHours): можно ли отдавать сейчас, когда откроется окно, осмысленно ли такое окно. Границы читаются из настроек один раз на объект — на 20 000 номеров иначе был бы 20 000-й запрос к базе. 3. Границы окна в общих настройках: миграция добавляет две колонки со значениями 10 и 20. Защита от повторного запуска пошаговая — прерванная ручная подача SQL на бою не должна оставить вторую колонку несозданной (урок В-80). Прав не требует: колонки наследуют привилегии таблицы. Запись схемы v9.12. 4. Админка «СМС»: два поля «Отправляем с / по» и объяснение, что часы — по местному времени получателя и клиент их не настраивает. Окно наизнанку «с 20 до 10» это отправка всю ночь, поэтому сервер его не принимает и говорит человеку почему. Проверяется ПОЛУЧИВШЕЕСЯ окно, а не присланные поля: правка одной границы тоже могла его вывернуть — журнал В-91. Прогоны: СМС 186/186 (было 171, 15 новых тестов), приём лидов 17/17, фронт 1658 зелёных и 3 намеренно пропущенных, phpstan по своим файлам 0, pint чисто, проверка типов без новых ошибок. Вырезанием проверено дважды: убрал проверку окна — покраснели два теста; убрал чтение границ с сервера на экране — покраснел фронтовый тест. Живьём: окно 11–19 сохранилось и пережило перезагрузку, «с 20 до 10» отклонено с человеческим текстом, вернул 10–20. Попутный урок В-92: два моих же новых теста сперва зеленели по неверной причине — ругань приходила за пропущенные поля платы за имя, а не за окно. Теперь тесты шлют полное письмо и проверяют, за какое поле ругаются. |
||
|
|
68fe632cca |
fix(телеграм-реклама): правки по сводному код-ревью ветки — деньги, статус-машина, робот, RLS
Закрывает находки ревью: C1-блокер + рассинхроны длины + все оранжевые. TDD, всё зелёное. Поведение в песочнице не меняется; правки готовят ветку к боевому включению. F1 (блокер): AdWalletService::freeze реактивирует released-hold через updateOrCreate по 4-ключу — пересдача кампании и повторная заявка на имя больше не падают на дубле ключа 23505 в боевом режиме. F2: длины валидации выровнены под колонки БД — имя 64, ad_link 500, ord_category 200; длинное значение даёт ошибку поля, а не замаскированный 422 от БД. F3: авто-рассылка морозит потолок бюджета симметрично ручному запуску только в бою и считает дневной лимит под lockForUpdate строки правила. F4: кампания не зависает в moderating вечно — переход moderating→needs_review плюс предохранитель уборщика по возрасту client_tg.moderation_stuck_hours=48, бронь не трогаем. F5: единое осторожное правило возврата брони в finalize и failed — есть mts_campaign_id значит могла уйти на модерацию → needs_review без release; нет id → failed плюс возврат брони. F6: робот cabinet.js — денежные кнопки оплатить/списать/запустить в чёрном списке domClickButton, finalize целит только кнопку отправки на модерацию. F7: finalize live не врёт launched:true на шаге /payment — launched:false, stoppedAt:payment; не дошли до /payment → падаем громко. F8: assertCostWithinCap подключён в live-finalize — сверка фактической стоимости с потолком. F9: GRANT SELECT служебным ролям на client_tg_campaigns миграцией 000016 — иначе кросс-тенантные джобы Poll/Sweep видели бы 0 строк на проде; правка ложного комментария в 000011. CHANGELOG v8.93, rls-reviewer CLEAN. ДЕПЛОЙ: ПЕРЕзапустить db/03_service_bypass_policies.sql. Приёмка: бэкенд ClientTg 196/196; робот npm test 89/89; pint/phpstan/deptrac чисто. Фронт не трогали. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
03ef89e06e |
fix реклама за показы: чужой документ теперь отказывает сама база, а не дисциплина в коде
Проверка прав доступа по прошлой миграции вскрыла утечку: внешний ключ на сообщение не защищал от чужого клиента, потому что проверки целостности в PostgreSQL идут в обход RLS, а робот ходит под ролью с кросс-тенантным доступом. Он молча увёз бы документ одного клиента в модерацию кампании другого. Обе дыры воспроизведены вживую до правок. v9.13 — составной ключ по кампании: документ обязан принадлежать той же кампании. v9.15 — составные ключи по клиенту на заданиях и на ленте: клиент задания обязан совпадать с клиентом кампании. Понадобилась потому, что моя запись про v9.13 оказалась сильнее самой защиты — поймано повторной проверкой. v9.14 — GRANT SELECT на ленту служебной роли, иначе робот и админский экран увидели бы ноль строк молча. Заодно исправлены два неверных утверждения, написанных мной же: перезапуск 03_service_bypass_policies.sql в этом выкате обязателен, а не не нужен, и шапка журнала схемы врала только про счётчик записей, но не про номер версии. Три записи выкатываются только вместе. Проверка в коде задачи 16 остаётся вторым рубежом. Портал 350/350 в том числе на пересозданной с нуля базе, робот 60/60, все миграции проверены вверх-вниз-вверх, мест снятия заморозки денег по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a35b28cb15 |
feat реклама за показы: вид задания у робота — отвезти картинки, посмотреть, отвезти документ
Робот умел ровно одно — отвезти картинки в кабинет, и очередь молчаливо означала именно это. Теперь у задания есть вид: upload, inspect, deliver, плюс ссылка на сообщение ленты, документ из которого везём. Умолчание upload обязательно — задания, лежащие в очереди на момент выката, вида не имеют. Внешний ключ на сообщение НЕ защищает от чужого клиента: проверки целостности в PostgreSQL идут в обход RLS, а робот ходит под crm_admin_user с кросс-тенантным доступом. Дыра пока спящая — message_id в бою никто не пишет. Требование проверять принадлежность в коде записано в докблоке миграции, в журнале схемы v9.12 и в приёмочных строках задачи 16. Журнал схемы v9.12, а не v9.11 из плана: тот занят отметкой revived_at. Портал 345/345, робот 60/60, мест снятия заморозки денег по-прежнему четыре. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aa4b482d45 |
feat(телеграм-реклама): Этап 5 — «Своё имя» + «Авторассылка» из кабинета + предохранители трат
Закрывает Этап 5 плана docs/superpowers/plans/2026-07-27-telegram-module-hardening.md.
Клиент управляет именем отправителя и авторассылкой сам из кабинета; авто не тратит
без денег и сверх дневного лимита. TDD, весь бэкенд+фронт зелёный.
## 5.1 — предохранители авторассылки
Накопитель в БОЮ перед постановкой пачки проверяет: смета умещается И в свободный
остаток кошелька (balance−frozen), И в дневной лимит правила за вычетом трат за
сегодня. Не прошла — держим черновиком + Log::info('client_tg.auto_skipped', reason).
Дефолт daily_limit_rub=0 → авто выключено (само денег не потратит). В песочнице гейта
нет (деньги не трогаются, как ручной launch). Новые колонки client_tg_auto_rule:
daily_limit_rub, spent_today_rub, spent_date (счётчик за день, сброс при смене даты).
## 5.2 — клиентское API имени (SenderController)
GET /sender (статус + остаток грейса), POST /sender (завести), /sender/disable,
/sender/enable (suspended→active, идемпотентно по периоду — без двойной оплаты в месяц;
проверка средств ДО списания). Логика в TelegramSenderService (enableSender+snapshot),
контроллер тонкий. Всё скоуп тенантом.
## 5.3 — экран имени (TelegramSenderPanel.vue)
Самодостаточная панель: статус имени человеческими словами, дата оплаты, остаток грейса
при долге; suspended → «Отключено за долг» + «Включить»; нет имени → форма «Завести имя».
telegram.ts: fetchSender/createSender/disableSender/enableSender.
## 5.4 — экран авторассылки + API (AutoRuleController)
GET/PUT /api/telegram/auto-rule (вкл/выкл, объявление, порог, бюджет, дневной лимит).
Порог клиентский — новая колонка client_tg_auto_rule.batch_threshold (NULL → дефолт
конфига 367; ниже 367 API не даёт — минимум МТС). TelegramAutoRulePanel.vue: тумблер +
поля порога/бюджета/лимита. В списке кампаний авто-кампании (created_by=null) помечены
чипом «авто».
Обе панели встроены в AdvertisingTelegramView.
## Схема
Две аддитивные миграции на существующую таблицу client_tg_auto_rule (000014 daily_limit
+ счётчик за день; 000015 batch_threshold). RLS/GRANT не тронуты (табличный GRANT
покрывает новые колонки). Записи db/CHANGELOG_schema.md v8.91/v8.92; обе прогнаны через
rls-reviewer — CLEAN.
## Приёмка
TDD. Бэкенд ClientTg 177/177; phpstan/pint/deptrac чисто. Фронт полный набор
212 файлов/1538 тестов зелёные; vue-tsc+ESLint по нашим файлам чисто. Проверено вживую
в браузере (клиент demo, песочница): обе панели читают и пишут — правило сохраняется
точь-в-точь (порог/бюджет/лимит), имя заводится (pending, деньги не тронуты).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
51b9c20d0b |
fix(смс-клиент): повторный накат доделывает индекс ключа заказа + сторож прав по трём ролям
Две находки обязательного проверяющего доступа (приёмка Этапа 2, журнал В-80). Обе — про молчаливые поломки: ошибок нет, тесты зелёные, защиты нет. 1. Индекс ключа заказа мог тихо не создаться. Миграция делает два шага — колонку и уникальный индекс, — а защита от повторного запуска стояла общим выходом в начале: «колонка есть, значит всё сделано». На бою SQL подаётся руками; прервалась подача между шагами — колонка легла, индекс нет, повторный накат прошёл мимо. Дальше два одновременных запроса с одним ключом создали бы две рассылки и списали деньги дважды. Проверка стала пошаговой. Доказано вырезанием: до правки новый тест краснеет («защита от двойного заказа потеряна молча»), после — зелёный. Живьём на локальной dev-базе: индекс уронен руками, повторный накат его вернул. 2. Сторож прав спрашивал базу только про рабочую роль. А гардов с именами служебных ролей в миграциях семь, и опечатка внутри такого гарда ошибки НЕ даёт — права просто молча не выдаются. Ровно тот блокер выката, ради которого сторож и заводился (В-36). Теперь спрашиваем и crm_admin_user, и crm_supplier_worker — по матрице, сверенной со всеми GRANT'ами миграций, — и счётчики для всех трёх ролей. Доказано вырезанием: опечатка в имени роли внутри гарда → сторож краснеет с именем роли, таблицы и права. Прогоны: СМС 171/171 (было 169, два новых теста), приём лидов 17/17, pint чисто. Структура таблиц не менялась — запись в журнале схемы v9.11. |