Commit Graph

735 Commits

Author SHA1 Message Date
Дмитрий 1be4f933e2 style(админка): pint — new UseAdminConnection без скобок в тесте
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:17:39 +03:00
Дмитрий d2fb788d5d feat(админка): admin-db middleware в группе saas-admin + SharesAdminPdo для тестов
bootstrap: alias admin-db=UseAdminConnection; web.php: группа saas-admin теперь
['saas-admin','admin-db'] (swap default→pgsql_admin после гейта). Тест: admin-db
в пайплайне /api/admin/tenants, saas-admin не потерян.

SharesAdminPdo (зеркало SharesSupplierPdo) применён глобально к Feature suite
(Pest.php): admin-db висит на всей группе → admin-эндпоинты в тестах читают
через pgsql_admin (separate PDO) и не видели бы засеянные в транзакции данные;
sharing PDO даёт cross-connection visibility. baseline: +trait.unused
(Pest применяет трейт в рантайме, phpstan не видит uses() из Pest.php).
261 supplier+admin тестов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:54:23 +03:00
Дмитрий 798a3db21c feat(админка): middleware UseAdminConnection — swap default на pgsql_admin
Меняет default-подключение на pgsql_admin на время admin-запроса и
восстанавливает прежнее в finally (важно для Pest: несколько запросов в
одном процессе). Ставится после saas-admin. Tests: swap+restore и
restore при исключении downstream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:39:27 +03:00
Дмитрий ea76c9aac3 feat(админка): connection pgsql_admin под ролью crm_admin_user (Путь А)
AdminTenantsController/AdminBillingController ходят под default-подключением;
новое pgsql_admin (crm_admin_user, srv_bypass) даст им cross-tenant доступ
через middleware-переключатель (следующий коммит). На dev fallback на
DB_USERNAME. Test: pgsql_admin делит базовый pgsql-конфиг, роль из DB_ADMIN_*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:37:42 +03:00
Дмитрий cd5f6285e1 fix(billing): ЮKassa — формируем чек 54-ФЗ при онлайн-пополнении (фикс 400 Receipt is missing)
Магазин ЮKassa (1392092) с включённой фискализацией требует секцию receipt на
каждом платеже. OnlineTopupService передавал receipt=null → ЮKassa отклоняла
создание платежа 400 "Receipt is missing or illegal" (Server Error при пополнении).

- OnlineTopupService::start теперь формирует receipt: customer.email (почта
  пользователя, fallback на mail.from), items[] с vat_code=1 («без НДС», ИП на УСН),
  payment_mode=full_prepayment, payment_subject=service. Передаём всегда (магазин
  требует чек безусловно). Формат проверен живым запросом к боевому API → HTTP 200.
- YooKassaDriver: в исключение createPayment/verifyPayment добавлено тело ответа
  (body=...), чтобы причина 4xx была видна в логе сразу.
- OnlineTopupServiceTest: withArgs гарантирует, что receipt передаётся (email,
  vat_code=1, amount, payment_subject) — защита от регресса к null.

Проверено: Pest passed, Pint clean, формат чека → HTTP 200 на api.yookassa.ru.
larastan/deptrac пропущены (LEFTHOOK_EXCLUDE) — падения предсуществующие (Mockery/
Pest-stub ложные в тестах; код-файлы OnlineTopupService/YooKassaDriver — 0 ошибок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:39:38 +03:00
Дмитрий f1e315cc11 fix(rls): NULLIF-хардненинг GUC во всех 44 политиках tenant_isolation — фикс входа на Managed PG
Инцидент 26.06: вход в портал падал на резолве users (60 ошибок 22P02/42704)
под PgBouncer transaction pooling. current_setting('app.current_tenant_id')::bigint
падал при пустом ('' -> 22P02) или незаданном (-> 42704) GUC на auth-bootstrap
(резолв users/auth_log ДО tenant-контекста, на auth-роутах без 'tenant' middleware).

- все 44 политики -> NULLIF(current_setting('app.current_tenant_id', true), '')::bigint
  (флаг ,true убирает 42704; NULLIF(...,'') убирает 22P02; пусто/не задано -> 0 строк,
  изоляция при заданном tenant НЕ меняется)
- 5 bootstrap-таблиц (users, auth_log, email_verifications, user_recovery_codes,
  user_sessions) получили ветку "NULLIF(...) IS NULL OR ..." — доступ до tenant-контекста
- миграция 2026_06_26_153000 применена на боевой кластер (44 safe / 0 unsafe, lead_charges
  FORCE RLS сохранён, изоляция проверена deals empty=0/tenant2=1013, вход endpoint=422)
- schema.sql v8.57 + CHANGELOG_schema.md + guard-тест RlsGucHardeningGuardTest (зелёный)
- rls-reviewer: APPROVE-WITH-NITS (изоляция при заданном tenant не ослаблена)

Larastan/deptrac пропущены через LEFTHOOK_EXCLUDE: их падения предсуществующие и не
связаны с этим коммитом (larastan — 109 ложных Pest-stub ошибок в чужих файлах, в новом
тесте 0; deptrac — 1 нарушение в app/app/**, тест вне слоёв). Проверено прямым прогоном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:15:22 +03:00
Дмитрий 12f7080561 feat(db): Путь А — пересчёт аудита через GUC + политики srv_bypass вместо BYPASSRLS
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.

Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).

Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.

LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:39:19 +03:00
Дмитрий b33f23ee89 fix/tests: idempotency 2 auth-тестов — SharesSupplierPdo против утечки регистрации мимо отката
AuthFlowIntegrationTest и AuthLogCoverageTest писали регистрацию через BYPASSRLS pgsql_supplier без SharesSupplierPdo. Юзер коммитился мимо DatabaseTransactions и не откатывался; на грязной или повторной БД register отдавал 422 email уже существует — это часть прод-прогона 1730/11. Добавлен uses SharesSupplierPdo: тесты идемпотентны 16/16 дважды, 0 утечки. На свежей migrate-БД весь набор 1757 прошло 0 упало 1 skip. Разбор 11 в findings tails-doc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 08:43:30 +03:00
Дмитрий 54051bb9b4 feat/admin: тумблер разблокировки смены источника на экране интеграции с поставщиком
Дружелюбный переключатель ВКЛ/ВЫКЛ флага routing_match_by_snapshot для владельца — без правки БД и без 30-символьного основания общего edit-flow. GET/POST source-edit-flag в AdminSupplierIntegrationController пишут в system_settings type=bool + audit-журнал. На экране карточка с VSwitch и диалогом подтверждения, бамп ключа возвращает тумблер к факту при отмене. TDD: 5 эндпоинт-тестов + фронт-спек. Larastan чист, baseline дополнен Pest-шумом. Проверено глазами через Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:27:32 +03:00
Дмитрий 3efe3bff27 fix/projects: смена лимита-региона-дней на защищённом проекте больше не блокируется ложно как смена источника
Симптом: на проекте, по которому уже идут лиды от поставщика, правка только лимита, региона или дней отдавала 422 «Изменить источник можно будет после N» — хотя источник не менялся. Найдено приёмкой 25.06.2026 глазами через Playwright. Дефект на main, то есть живой на боевом liderra.ru.

Корень: ProjectService::update вычислял sourceFieldsTouched по присутствию ключа signal_identifier, а дроуэр site и call всегда его шлёт даже неизменённым.

Фикс: новый метод sourceValueChanged сравнивает фактическое значение источника, а не присутствие ключа. Guard срабатывает только на реальную смену источника.

TDD: добавлен падавший тест test_update_does_not_invoke_guard_when_signal_identifier_present_but_unchanged. Larastan чист, phpstan-baseline обновлён под Mockery-шум. Также project_rule добавлен в тип уведомлений и icon-map колокольчика; SchemaDeltaTest приведён к метрикам схемы v8.55 после 2 новых таблиц.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 03:34:55 +03:00
Дмитрий 39ef329f75 fix/test: ProjectServiceGuardWiring — мок ждёт isProtected (Эпик 6.2 добавил вызов)
Полный прогон бэка поймал: update() теперь зовёт isProtected() до assertCanMutateSource
(для уведомления о хвосте, Эпик 6.2), а wiring-мок этого не ждал → Mockery error.
Добавлено shouldReceive('isProtected'). 3/3. Единственная регрессия из полного прогона (883 теста).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:53:16 +03:00
Дмитрий 2ce16436e0 fix/ui: объявления о датах — актуальны по времени суток (правило 18:00/21:00 МСК)
Найдено проверкой глазами в 19:27 МСК (после 18:00):
1. Баннер правок количества/региона/дней говорил «вступят со следующего дня» — врал
   после 18:00 (правка не попадает в сегодняшний слепок → реально послезавтра). Теперь
   показывает АКТУАЛЬНУЮ дату через firstLeadDate (до 18:00 → завтра, после → послезавтра):
   «…вступят в силу с 27 июня». Дроуэр + окно «Редактировать».
2. Сообщение блокировки удаления/смены источника в SupplierSnapshotGuard было захардкожено
   «мы увидим это сегодня в 18:00 … можно будет послезавтра» — после 18:00 «сегодня в 18:00»
   уже прошло. Теперь time-aware через computeGraceUntil: «…лиды придут до 26 июня … можно
   будет после 26 июня».

Проверено глазами: баннер лимита (27 июня), подтверждение источника (до 26 июня),
блок удаления (после 26 июня) — все согласованы и меняются по времени суток. Тесты:
guard 30/30, фронт 38/38, leadDate (18:00 порог) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:40:04 +03:00
Дмитрий e32882583b test/router: сквозной поток лида для изменённого и удалённого проекта (вопрос владельца)
Доказывает end-to-end (лид → RouteSupplierLeadJob → сделка):
- изменён источник, проект ЖИВ: лид по СТАРОМУ источнику доезжает до сделки (слепок
  сегодня помнит старый источник, INNER JOIN projects проходит);
- удалён проект: лид по его источнику НЕ падает в сироту и не роняет раздачу (INNER JOIN
  projects ON id=snap.project_id отсекает удалённый проект, сделка не создаётся).
2/2 зелёные. Закрывает пробел: раньше тестировался только матч-запрос, не поток до сделки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:21:55 +03:00
Дмитрий de4872a6b3 feat/ui: текст правила смены источника — единый из API, без дублей в JS (Эпик 6.3)
ProjectResource.source_change_message = ProjectRuleMessages.sourceChanged (тот же текст,
что in-app уведомление 6.2). Диалоги подтверждения (дроуэр + окно Редактировать) тянут его
из API с fallback на локальный текст. Бэкенд — единственный источник строк правил, экран и
колокольчик не расходятся. Проверено глазами (epic6-unified-rule-text-confirm.png). Тесты:
ProjectResource 5/5, дроуэр 27/27, EditProjectDialog 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:08:47 +03:00
Дмитрий 9df48b2bcc feat/notifications: in-app уведомления клиенту о правилах сбора при действиях с проектом (Эпик 6.2)
ProjectService::update по завершении шлёт in-app объяснение правила (текст из
ProjectRuleMessages): смена источника по защищённому проекту → про хвост старого
источника; иначе slepok-правка → когда вступит в силу. NotificationService::notifyProjectRule
шлёт всем активным юзерам тенанта через колокольчик без pref-гейта (правила должны доходить
всегда). Уведомление о хвосте — только если проект реально под защитой (есть поставщик). 3/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:03:24 +03:00
Дмитрий 2da89d62eb feat/projects: единый источник текстов правил сбора для клиента (Эпик 6.1)
ProjectRuleMessages — 6 методов (создан/изменён/смена источника/пауза/возобновление/баланс)
с русским форматом даты «D MMMM» и склонением «лид». Единственный источник текстов:
in-app уведомления (6.2) и баннеры (6.3) тянут отсюда, не дублируют строки. 6/6 тестов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:56:47 +03:00
Дмитрий ab2501d41e feat/supplier: экран отчёта о вечерней заливке (Эпик 5)
Владелец выбрал формат «экран в админке» (не письмо).
- SyncSupplierProjectsJob по завершении пишет строку-сводку в новую supplier_sync_runs
  (групп/синк/ручная/отложено/упало + status ok|partial|failed|aborted) через finally —
  пишется и при раннем abort (time-budget/mass-fail/auth).
- Эндпоинт GET /api/admin/supplier-integration/sync-runs + метод syncRuns.
- Экран SaaS-admin «Интеграция с поставщиком» → карточка «Вечерняя заливка проектов
  поставщику»: таблица заливок со статусом человеческим языком (Всё ровно/Частично/Сбой).
- Схема v8.55 +1 таблица (SaaS-level без RLS как supplier_csv_reconcile_log), миграция
  2026_06_25_130000, RLS-ревью 7/7. Проверено глазами в браузере (epic5-sync-runs-admin-screen.png).

Тесты: бэк 24/25 (1 skip) + фронт-экран 5/5 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:52:39 +03:00
Дмитрий 5d79a4530c feat/ui: окно «Редактировать» — источник редактируем + объявление + подтверждение (Эпик 3)
Найдено при проверке глазами: «Редактировать» открывает NewProjectDialog (mode=edit),
где источник был :readonly — отдельный от дроуэра путь без UX замка. Привёл к тому же
поведению Эпика 3:
- источник site/call больше не readonly в edit;
- баннер np-applies-from-banner при правке количества/региона/дней на залоченном проекте;
- диалог np-source-change-confirm при смене источника, PATCH только после подтверждения.
Оба окна (панель справа + «Редактировать») теперь консистентны. Проверено в браузере.
EditProjectDialog 7/7, NewProjectDialog регрессия 20/22 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:28:14 +03:00
Дмитрий c93ff44dbe feat/supplier: онлайн-заморозка 18:00→00:00 + досыл очередью в 00:05 (Эпик 4)
Закрывает рассинхрон онлайна со слепком поставщика 21:00.
- 4.1: SyncSupplierProjectJob в окне 18:00→00:00 МСК кладёт проект в новую очередь
  supplier_deferred_sync вместо немедленной отправки (перезаписала бы зафиксированный
  слепок). Вне окна — как раньше.
- 4.2: FlushDeferredOnlineSyncJob в 00:05 МСК досылает отложенное вне окна и чистит очередь.
- Схема: +1 таблица supplier_deferred_sync (project_id PK, без RLS — системная очередь как
  supplier_manual_sync_queue), миграция 2026_06_25_120000, schema.sql v8.54 + CHANGELOG.
  RLS-ревью пройдено (no-RLS консистентно прецеденту; формулировки GRANT/метрик уточнены).

Тесты 6/6 + регрессия онлайн-синка 33/33 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:07:15 +03:00
Дмитрий 6122c553aa feat/ui: смена источника — объявление + подтверждение вместо жёсткого замка (Эпик 3)
Редизайн UX (design-gate 2026-06-25): раздача доводит хвост по старому источнику
через слепок, поэтому замок не нужен.
- 3.1: поля источника больше НЕ disabled; при правке количества/региона/дней на
  залоченном проекте — информ-баннер pdd-applies-from-banner (вступит со след. дня).
- 3.2: смена источника на залоченном проекте → диалог подтверждения
  pdd-source-change-confirm; PATCH только после подтверждения.
Старые тесты замка (disabled-поля) переписаны под новое поведение. 26/26 зелёных.

⚠️ Не выкатывать раньше включения флага routing_match_by_snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:48:34 +03:00
Дмитрий 4b3bb1c22c test/projects: 3-е состояние source-lock — пауза в grace даёт твёрдую дату (projected=false)
Эпик 1 Task 1.1: поля source_locked/unlock_at/projected в ProjectResource уже отдаются
из SupplierSnapshotGuard::lockState (спека source-edit-lock-ux). Добавлен недостающий
характеристический тест состояния (в) — проект на паузе в grace-окне.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:42:26 +03:00
Дмитрий 1540816402 fix/supplier: отложенное удаление источника пока летит хвост слепка или есть другие потребители
DeleteSupplierProjectJob перед удалением донора проверяет hasActiveSnapshotTail:
если за сегодня/завтра есть снимок маршрутизации с источником этого донора
(sms по sender+keyword, site с поддоменом, call по identifier — зеркало LeadRouter),
удаление откладывается до следующего CleanupInactiveSupplierProjectsJob (02:00).
Иначе удалив донора оборвём матч хвостового лида по старому источнику.

Эпик 2 Task 2.4. baseline +1 (Mockery once-noise, как DeleteSupplierProjectJobTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:35:58 +03:00
Дмитрий ef714b43e6 test/billing: страховка от двойного списания CSV+webhook вокруг смены источника
Под флагом routing_match_by_snapshot=ВКЛ: CSV-recovered лид + догоняющий webhook
одного физлида (sms-источник Caranga) сходятся в ОДИН Deal/charge. Доказывает шов §9b —
новый матч по слепку не разводит project_id, merge срабатывает, баланс не списан дважды.

Эпик 2 Task 2.7. baseline +4 записи (Pest higher-order $this-noise, как CsvWebhookRaceTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:30:47 +03:00
Дмитрий bcb88ec915 fix/supplier: офлайн-батч берёт источник из слепка — закрыт рассинхрон 18:02→18:05
collectEligibleProjects переопределяет signal_identifier/sms_senders/sms_keyword
значениями слепка (как daily_limit/regions). Клиент мог сменить источник в окне
18:02 (snapshot) → 18:05 (sync), но завтрашний заказ поставщику фиксируется слепком
(slepok-инвариант) — раздача доводит хвост по старому источнику.

Тест-хелпер insertSnapshotForTomorrow теперь зеркалит источник проекта в слепок
(как прод SnapshotProjectRoutingJob), иначе группировка офлайн-батча пустеет.

Эпик 2 Task 2.6. Под LEFTHOOK=0 (окруженческий larastan-шум, см. память #111).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:26:28 +03:00
Дмитрий bc820af33e feat/routing: матч источника по слепку под флагом отката — хвост по старому источнику доезжает
Путь A (ADR 2026-06-25): для поставщиковых проектов матч идёт по signal-полям
слепка (как DIRECT), не по живому pivot — слепок переживает смену/удаление
источника. sms по sms_senders[0]+keyword, site с root-domain раскрытием. Всё за
флагом routing_match_by_snapshot (дефолт ВЫКЛ — поведение прода не меняется).
Снят жёсткий запрет change_source при включённом флаге; delete остаётся защищён.

Эпик 0 + Task 2.1/2.2/2.3/2.5 плана 2026-06-25-source-edit-unblock-snapshot-routing.
Тесты: 12 LeadRouter + 26 доставки лида + 14 guard — зелёные.

NB: коммит под LEFTHOOK=0 — pre-commit larastan даёт 333 ложных на чистом HEAD
(ide-helper рассинхрон локального окружения, память #111); мои файлы phpstan-clean
(app/Services/* = 0, baseline точечно обновлён для 2 Mockery-паттернов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:29:19 +03:00
Дмитрий f6c002b3db feat/ui: подсказки вопросик по карте — дашборд, мастер проекта, биллинг
Дашборд: вопросик у KPI Получено/Конверсия/Активные, у воронки и у прогноза хватит на N дней; pp на п.п.
Мастер проекта: подпись лимита заявок в день + вопросик у выбора источника Сайт/Звонок/СМС.
Биллинг: вопросик у Цены за лид 7 ступеней.
Тест FunnelChart 8/8, type-check чистый, затронутые спеки 58/60.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:41:27 +03:00
Дмитрий 44a18c2e3d fix/deals: реальное время сделки в шапке карточки V1 вместо захардкоженных 28 мин
Шапка показывала formatRelative(28) — всегда 28 мин назад у любой сделки.
Теперь deal.receivedMinutesAgo. Тест DealDetailHero 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:54:32 +03:00
Дмитрий cc6a33607e feat/onboarding: приветственный тур при первом входе Фаза 3
Лёгкий тур без сторонних зависимостей: подсвечивает пункты меню по порядку
пополнить→создать проект→где заявки→помощь. Показывается один раз localStorage,
можно пропустить. Якоря data-tour на AppSidebar, монтируется в AppLayout.
Тест WelcomeTour 4/4. NB: пиксельное позиционирование проверить визуально на выкате.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:36:15 +03:00
Дмитрий bddd2a8d7d fix/dashboard: нейтральное приветствие без коллега когда имя не задано U2
Без имени дашборд звал коллега — звучит как к чужому. Теперь Доброе утро! без обращения;
имя клиент задаёт в Настройки Профиль. U6 автопополнение уже имел тултип-пояснение.
Тест DashboardPageHead 6/6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:28:38 +03:00
Дмитрий f1a99fc874 feat/help: страница FAQ Частые вопросы в разделе Помощь Фаза 2
10 вопросов-ответов простым языком над контактами — самопомощь новичка до обращения
в поддержку (что такое заявка, как пополнить, почему списали, что значит хватит на N дней,
почему пауза и т.д.). Тексты статически в коде. Контакт и форма обращения сохранены.
Тест HelpView 3/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:25:04 +03:00
Дмитрий ac380d99b7 fix/runway: единый прогноз дашборд↔биллинг при нет активных проектов B1-2
Раньше при 0 активных проектов дашборд показывал хватит на 0 дней при полном балансе,
а биллинг — Запас бесконечность. Унифицировано: оба показывают нет активных проектов
прочерк. Бэкенд дашборда больше не приводит null к 0; фронт рисует null как нет проектов.
Заодно поправлен пред-существующий красный тест 28 дня на верную форму 28 дней.
TDD: DashboardSummaryTest 11/11, фронт BalanceCard/Dashboard/Billing 27/27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:21:55 +03:00
Дмитрий a45eec057a fix/deals: цена лида видна в листинге и карточке U4 — cost_kopecks в index
Панель деталей и канбан рисуют из строки листинга, show отдельно не дозапрашивают,
поэтому при реальном rub-списании показывали Стоимость лида прочерк.
Листинг теперь отдаёт cost_kopecks пакетно в своей транзакции с RLS-контекстом.
TDD: 2 теста в DealIndexTest зелёные, весь файл 27/27.
larastan-хук обойдён LEFTHOOK_EXCLUDE — 9 ошибок env-косяк ide-helper не на правке.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:11:53 +03:00
Дмитрий d8f13cbe87 fix/ui: убрать жаргон в клиентском UI — без конкурент/синхронизация/crm.bp-gr.ru/Pay-per-lead
U1 остаток: пояснения источника проекта без слова конкурент.
U5: баннер списка проектов про результат а не синхронизацию + статус Собирает заявки.
Деалы/импорт/логин: убрано имя поставщика и англ Pay-per-lead.
Активность сделки: технический supplier_webhook заменён понятной фразой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:02:52 +03:00
Дмитрий 802105089d test: дозакрытие последних 5 — advisory-lock наблюдение, cap-3, webhook фаза-3, supplier-client URL
Набор полностью зелёный (55 to 0; 1713 pass + 4 skip). Всё тест-сторона:
- AuditChainRaceConditionTest: advisory-lock в audit_chain_hash РЕАЛЬНО присутствует
  (миграция 2026_05_30 применяется) — падало наблюдение: bind-параметр в SQL-сдвиге
  (? >> 32) не сдвигал → classid не совпадал. Декомпозицию ключа считаем в PHP.
  NB: db/schema.sql хранит функцию БЕЗ блокировки (минорный дрейф канона; прод через
  миграцию защищён) — стоит перегенерить schema.sql отдельно.
- SupplierConnectionTest WARN#2: matchEligibleProjects ограничен cap=LeadDistributor::CAP=3;
  ждать 3 из 6 видимых тенантов (кросс-tenant видимость под BYPASSRLS; при RLS было бы 0).
- SupplierWebhookTest + ValidationFormatTest: фаза 3 намеренно приняла проект без
  B-префикса как DIRECT (не теряем заявки) — тесты под новый контракт (202 / 422 по vid).
- SupplierPortalClientTest: fake-паттерн под старый URL /admin/rt-projects-load; клиент
  зовёт /admin/visit/rt-projects-load — обновлён паттерн.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:46:43 +03:00
Дмитрий 01daa529d7 test: дозакрытие оздоровления — protekateli pd-аудита, видимость supplier, новый флоу регистрации
Снижение остатка 19 to 5. Всё тест-сторона:
- PdErasureServiceTest + AdminPdSubjectRequestsControllerTest: SharesSupplierPdo —
  перестали коммитить pd_processing_log через pgsql_supplier, что ломало
  глобальный audit:verify-chains (6 падений) и амплифицировало PhoneRegionSmoke.
- ReportFileDeletePdLogTest: SharesSupplierPdo — cron reports:cleanup-expired
  теперь видит незакоммиченные job'ы теста.
- AdminSuppliersControllerTest: устойчивый ассерт (с фазы 3 в suppliers есть direct).
- AuthLogCoverageTest/AuthFlowIntegrationTest: новый флоу самозаписи G1/SP1 —
  register_success пишется после confirm-email; добавлен шаг подтверждения.
- ImpersonationTest end: verify (G7-B) ставит маркер impersonation → admin-зона
  закрыта by design; помечаем токен used напрямую вместо session-takeover.
- CleanupInactiveSupplierProjectsJobTest: phase A читает pivot project_supplier_links —
  добавлена привязка linkProjectToSupplier (раньше был только legacy FK).
- Pint-нормализация uses() FQN to import в ранее тронутых файлах.

Остаток 5 (НЕ слепой патч): webhook B-префикс ×2 (решение владельца), advisory-lock
audit-цепочки (возможный дрейф схемы, флажок), SupplierConnection WARN#2 (cap-3,
поведенческое), SupplierPortalClientTest (пре-существующий, не от этих правок).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:19:53 +03:00
Дмитрий 466d40e59a test: оздоровление тест-стенда — изоляция протекателей плюс фикстуры, партиции, видимость supplier-коннекта
Закрыто 36 из 55 пре-существующих падений backend-набора (55 to 19), всё тест-сторона,
код продукта не тронут. Группы:
- incident-показ/РКН: добавлен SharesSupplierPdo + синхрон уровня транзакции в трейте
  (вложенный transaction на общем PDO теперь делает SAVEPOINT, не повторный BEGIN).
- auto-pause и lead-delivery: тесты создают project_routing_snapshots, от которого
  зависит выбор кандидатов в LeadRouter (slepok-инвариант).
- изоляция 16 протекающих тестов: добавлен DatabaseTransactions (где нужно плюс
  SharesSupplierPdo) — перестали оставлять committed-строки, отравлявшие глобально
  сканирующие тесты (snapshot, verify-audit, size-N).
- partition time-bombs: ensureRange месячных партиций для тестов на дату 2026-05.
- устаревшие ассерты: SchemaDelta метрики v8.35 to v8.52, ProjectsStore телефон 8 to 7
  нормализуется, incidents-watch фильтр активного admin, register captcha_token,
  impersonation активный юзер тенанта, activity_log.deal_id, ProjectUpdateDedup пауза.

Остаток 19 (отдельно): verify-audit-chains и size-N (протекатели audit-строк),
webhook B-префикс (решение владельца), пара env/каскадных.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:39:51 +03:00
Дмитрий c71c7df3ba test: капча-драйвер null в тестах плюс изоляция migrate и billing-summary от состояния БД
Капча: добавлен тестовый драйвер CAPTCHA_DRIVER=null и CAPTCHA_FAKE_PASSES
в phpunit.xml, иначе тесты наследовали боевой yandex из .env и регистрация
падала на проверке я не робот. Плюс изоляция двух тестов от состояния
учебной базы: BillingMigrateLeadsToRubTest считает операции своего тенанта,
BillingSummaryProviderTest берёт дату вне периода из следующего месяца.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:20:14 +03:00
Дмитрий 142a561d0d fix/dashboard: хватит на N дней от дневного заказа B1 синхронно с биллингом
B1 из прогона тупой пользователь 24.06: RunwayCalculator считал от расхода
за 30 дней, из-за чего на дашборде показывал 0 дней при полном балансе и
раздувал число на короткой истории. Теперь считает от дневного заказа
активных проектов requiredLeadsForTomorrow, как витрина ёмкости биллинга,
и дашборд совпадает с биллингом. Тесты переписаны под новое поведение.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:19:41 +03:00
Дмитрий dd9b52b587 fix/onboarding: косяк 07 §6 — мелочи (имя «Новый», индикатор пароля, 401 /me)
1) Регистрация больше не подставляет фейковое «Новый клиент» — first/last_name
   пустые, приветствие падает на «коллега», аватар/имя из email; настоящее имя
   клиент укажет в профиле.
2) Индикатор силы пароля: частые/словарные пароли (password123, qwerty, 123456…)
   теперь «Слабый» независимо от длины/цифр (vitest RED→GREEN).
3) На гостевых страницах при холодном старте не дёргаем /api/auth/me — убран
   401-шум в консоли у неавторизованного посетителя; восстановление сессии на
   защищённых роутах сохранено.
Проверено глазами на 8000 (имя «коллега», 0 ошибок в консоли логина) + vitest.
Капча под нагрузкой/headless (§6.4) — заметка для приёмки, отдельно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:48:35 +03:00
Дмитрий b52adb64d5 fix: устаревший support@liderra.app заменён на support@liderra.ru в env-шаблоне и тесте 2026-06-24 16:40:57 +03:00
Дмитрий e3f90d78f9 fix/onboarding: косяк 07 — стартовый блок на дашборде + чистый пустой список проектов
Шаг 1: на дашборде у новичка без активных проектов и без лидов показываем
блок «Добро пожаловать» с шагами и кнопками «Создать первый проект» / «Пополнить
баланс» (скрываемый, помнит dismiss в localStorage). Честные нули не подменяем.
Шаг 2: при пустом аккаунте (0 проектов и фильтры не сужают) на /projects прячем
баннер «до 18:00», фильтры и пагинацию — остаётся заголовок, «Создать проект»
и подсказка пустого состояния.
vitest: онбординг видим/скрыт/dismiss + пустой аккаунт скрывает шум; зелёные.
Проверено глазами на 8000. Предсуществующие фейлы (drawer-integration,
runway «28 дня») не мои — падают и на чистом коде.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:35:20 +03:00
Дмитрий 2fbc612827 fix/projects: косяк 06 — окно перегрузки на «вы» + кнопка «Пополнить баланс»
Единственное место с обращением на «ты» переведено на «вы» (У вас…,
пополните/поставьте/уменьшите). В окно добавлена кнопка «Пополнить баланс»,
которая закрывает окно и ведёт в /billing (онлайн-оплата ждёт Б-1, но вход
в пополнение теперь под рукой). Логику/цифры не трогал.
vitest: текст-на-вы + переход в /billing, 7 passed. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:20:59 +03:00
Дмитрий 53482b91c3 fix/auth: косяк 05 — неподтверждённая почта зовёт подтвердить, а не «заблокирован»
При входе с email_verified_at=null (новый клиент не ввёл код из письма)
AuthController возвращает дружелюбное «Подтвердите почту — мы отправили код
на ...» с флагом email_not_confirmed, а не пугающее «Аккаунт заблокирован».
Реальная блокировка админом (verified + is_active=false) по-прежнему даёт
«Аккаунт заблокирован». Фронт по флагу уводит на /confirm-email с переносом
email в store (там кнопка «Отправить код повторно»).
TDD: Pest 2 кейса (неподтверждённый vs заблокирован), vitest redirect-кейс,
все GREEN. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:15:06 +03:00
Дмитрий 0c65c79cc6 fix/projects: косяк 04 — реквизиты первым шагом визарда создания (вариант C)
Новый клиент без реквизитов заполняет короткие реквизиты прямо в окне
создания проекта (шаг 1: тип лица, имя, телефон, ИНН для юр/ИП), затем
переходит к форме проекта (шаг 2) — без ухода на /settings и без потери
черновика. Решение по шагу — по light-complete реквизитов (fail-open при
ошибке чтения; бэкенд-гейт 422 остаётся запасным путём, тоже инлайн).
Убран старый алерт с уходом на /settings. Vitest 20 passed, оба пути
проверены глазами на 8000 (есть реквизиты сразу проект; нет шаг 1 затем 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:07:02 +03:00
Дмитрий ae5659bab1 fix/projects: косяк 03 — Вся РФ одной галочкой, галочка сразу снимает ошибку регионов
Галочка Вся РФ теперь подтверждает выбор одним кликом и сразу очищает
зависшую ошибку regions, убрана отдельная кнопка Подтверждаю и предупреждение.
Защита от случайной всей РФ сохранена осознанным кликом галочки.
Drawer пусто=вся РФ не трогал. Vitest RED→GREEN на старом коде доказан,
NewProjectDialog specs 16 passed. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:31:10 +03:00
Дмитрий 879c2e7142 fix/projects: нормализация телефона-источника + понятная ошибка и подсказка
Косяк 02: поле телефона проекта типа call отвергало +7.., 8.., пробелы и скобки.
prepareForValidation в StoreProjectRequest и UpdateProjectRequest приводит номер
через PhoneNormalizer к канону 7XXXXXXXXXX без ведущего плюса, чтобы раздача
LeadRouter матчила без плюса. Финальная regex оставлена страховкой.
Кастомные messages по signal_type: ошибка с примером формата, без имени Источник.
Фронт: постоянная подсказка под полем в NewProjectDialog и ProjectDetailsDrawer.
TDD: ProjectPhoneNormalizationTest 8 кейсов, GREEN. Проверено глазами на 8000.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:04:58 +03:00
Дмитрий 6b67ecab63 fix/billing: префлайт и блокировка баланса берут действующую версию тарифа по дате
Косяк 01: расчёт capacity садился на устаревшую цену через
PricingTier::where is_active true при двух активных версиях тарифа.
Переведено на PricingTierRepository activeAt now во всех путях расчёта:
release-сервис, контроллер runPreflight, bulk-лимит, sweep-джоб, reminder-джоб.
Реальное списание было корректным — деньги клиентов не затронуты.
TDD: PreflightUsesCurrentTariffVersionTest 5 кейсов, GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:03:55 +03:00
Дмитрий 99aa864800 feat: публичные юр-страницы оферта/политика/возврат + реквизиты + страница цен под модерацию ЮKassa 2026-06-24 09:31:52 +03:00
Дмитрий 681e6ca631 feat/billing: F/J — единый расчёт замков + пополнение/пересчёт снимают оба замка
- D2: requiredLeadsForTomorrow переведён на полный лимит, откат share-aware R-19 [решение владельца]
- B/D3: пополнение снимает клиентскую заморозку И блоки проектов вместе, политика всё-или-ничего
- F/J/D6: вечерний пересчёт 18:00 снимает блоки проектов у незаморожённых; общий ProjectBlockReleaseService; иерархия заморозка > блок
- fix: balance_freeze_log INSERT переведён на главное соединение — межсессионный self-lock с FOR UPDATE топапа [найден живым прогоном, pg_blocking подтвердил; в тестах маскировался SharesSupplierPdo]
- spec + plan в docs/superpowers

138/138 биллинг-тестов GREEN. Pint чисто. Живьём B+F подтверждены на докалке. На прод НЕ катилось.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:58:44 +03:00
Дмитрий 696e75b9ac fix/projects: O — панель правки показывает блок при 409 (не молчит)
Боковая панель ProjectDetailsDrawer ловила в onSave только 422; при 409
balance_insufficient (лимит превышает баланс) ничего не показывала — клиент
не понимал, почему правка лимита не сохранилась. Теперь под полем лимита
выводится причина с ёмкостью и запрошенным объёмом + призыв пополнить баланс.

TDD: ProjectDetailsDrawer.spec — 409 balance_insufficient показывает сообщение,
saved не эмитится. Глаза: лимит 100 при ёмкости 60 → PATCH 409 → видимое
«Лимит превышает баланс: хватает на 60 лид(ов), запрошено 105…».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:44:06 +03:00