Задача 8e. SaaS-оператор видит кампании в статусе queued по всем тенантам и
вписывает номер оформленного в конструкторе Яндекса адаптивного креатива
yandex_creative_id, после чего кампанию можно запускать. Два эндпоинта
AdminAdvertisingController campaignsAwaiting и setCampaignCreative под
saas-admin+admin-db, карточка в AdminAdvertisingView, api-функции и типы.
🔴 Запись строго через сырой DB::table update только колонки yandex_creative_id
Eloquent-билдер добавил бы updated_at, а колоночный GRANT у crm_admin_user
разрешает писать лишь эту колонку тесты идут под суперюзером и это не ловят.
RLS не трогаем ad_campaigns уже покрыт srv_bypass и грантами.
Бэкенд AdminAd 19/19, реклама-модуль 195/195, фронт админки 11/11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Задача 8b. Админ-экран «Реклама: расход и маржа» переведён со старой модели
наценка 30% делением на единую модель показов наценка 40% вычитанием, как в
CampaignImpressionCharger. yandex_cost = client_spend умножить на долю
1 минус ad_margin_percent/100. Поле ответа markup_percent переименовано в
ad_margin_percent, фронт-вью и тип обновлены.
Мёртвый класс AdMarkup и его тест удалены полностью. Колонка ad_settings
markup_percent осталась в схеме, но больше не читается. Тесты: бэк
AdminAdvertisingSpend 6/6, реклама-модуль 189/189, фронт админки 7/7.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Часть 5c — баннеры: клиент грузит свой готовый файл на каждый из 15 размеров вместо автогенерации из одной картинки. Частичное утверждение флагом included, замена и удаление отдельного баннера, валидация точного размера и веса. Админ-поле цены за 1000 показов. Пример CSV для скачивания и подъём лимита загрузки.
Часть 5d — два режима сбора аудитории. Авто: скользящее окно, обновляется ежедневно, только контакты системы. Ручной: снимок сделок за период плюс свой список номеров и срок показа. Клиент сам задаёт цену за 1000 показов с дефолтом из админки. Наценка настраивается в админке, по умолчанию 40 процентов, в Директ уходит меньше, клиенту не видна нигде.
Миграции: ad_campaign_banners += included; ad_campaigns += mode/snapshot_from/snapshot_to/run_days/client_cpm_rub; ad_settings += ad_margin_percent. RLS-ревью PASS на всех миграциях. Backend 166 тестов, фронт 123 теста, сборка чистая. Маржа и yandex_cost_rub клиенту не сериализуются.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AdminTenantsView грузил всех тенантов разом и фильтровал в браузере — на 1000
клиентов поиск/чипы видели только первую страницу. Теперь страница из limit/offset
+ v-pagination; поиск (ILIKE), статус (производный trial/overdue/active/suspended)
и тариф — серверные multi-фильтры. AdminTenantsController::index: statuses/tariffs
через CASE/whereIn (статус зеркалит adminTenantsMapper.deriveStatus). Опции тарифов —
отдельным запросом listAdminTariffPlans. Демо локально подтверждено.
Тесты: фронт 34/34 (tenants), бэкенд 13/13 (+2 на statuses/tariffs); baseline getJson 13→15.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json.
gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Закрывает дыру #4 аудита журналирования. Объём по выбору заказчика — МИНИМУМ:
✅ Админ-API + кнопка в админке для удаления ПДн субъекта
✅ Сервис анонимизации (users + supplier_leads + deals + webhook_log)
✅ Журнал факта удаления в pd_processing_log
❌ БЕЗ формы самообслуживания на стороне субъекта
❌ БЕЗ email-подтверждения
❌ БЕЗ 30-дневного SLA (trigger deadline_at уже в схеме)
Что добавлено:
* Eloquent-модель `App\Models\PdSubjectRequest` (таблица уже была в схеме)
* Сервис `App\Services\Pd\PdErasureService::eraseSubject()`:
- cross-tenant через pgsql_supplier (BYPASSRLS)
- транзакционно (rollback при ошибке)
- users: email→erased-{id}@deleted.local, first_name→Удалено, last_name→null,
phone→+7000{id}
- supplier_leads: phone→+7000XXXXXXX, raw_payload→{erased:true}
- deals: phone→+7000XXXXXXX, contact_name→Удалено (только если есть phone)
- webhook_log: batched UPDATE по 500, raw_payload→{erased,erased_at}
- pd_processing_log запись action=deleted за каждого user/lead с
actor_admin_user_id (hash-chain audit_chain_hash триггером сам подписывает)
- При requestId — pd_subject_requests SET status=completed, completed_at,
response_text счёт
* Контроллер `AdminPdSubjectRequestsController`: index/show/store/executeErasure
* Маршруты под middleware(saas-admin): GET/POST /api/admin/pd-subject-requests,
GET /{id}, POST /{id}/erase
* Vue: `AdminPdSubjectRequestsView` (Quiet Luxury, таблица + диалог создания +
кнопка Анонимизировать для request_type=deletion); ESLint требует
v-slot:[`item.X`]= вместо #item.X для динамических slot-имён с точкой
* Пункт меню в AdminLayout.vue + route /admin/pd-subject-requests
NB: реальная схема — users.first_name/last_name/phone/email; supplier_leads
имеет только phone (нет contact_*); deals имеет phone+contact_name (нет
contact_email); webhook_log JSONB. PdErasureService адаптирован под факт.
Тесты: 12/12 passed (63 assertions, ~2.6s) — index pagination, store +
deadline trigger (+30 дней), eraseSubject анонимизация user/lead/deal/log,
pd_processing_log запись, request status→completed, отклонение
не-deletion типов, gate saas-admin, InvalidArgumentException.
Plan: docs/superpowers/plans/2026-05-23-7-holes-overview.md (#4).
Закрывает gap из v1.66 — mock-форма имеет mrrRub, но API возвращал null.
Теперь AdminTenantsView показывает реальную колонку MRR.
Backend (AdminTenantsController::index):
- Добавлено tariff_plans.price_monthly as tariff_price_monthly в select.
- mrr_rub в response: price_monthly (string) если не-trial; иначе null.
- Aggregate-формат как у /admin/billing — string чтобы decimal не терял
точность при передаче через JSON.
Pest +3 (AdminTenantsIndexTest):
- mrr_rub='990.00' для активного тарифа не-trial.
- mrr_rub=null для trial (даже если тариф есть).
- mrr_rub=null если current_tariff_id отсутствует.
Frontend:
- ApiAdminTenant.mrr_rub: string | null в типе.
- mapApiAdminTenant: parseFloat(api.mrr_rub) или null (вместо hardcoded
null из v1.66).
- AdminTenantsView: formatRub(item.mrrRub) для консистентности с другими
₽-полями.
Vitest +2:
- mrr_rub строка → number.
- mrr_rub=null → mrrRub null.
PHPStan baseline регенерирован. cspell-glossary +консистентности.
Регресс:
- Lint+type-check+format passed.
- Vitest 313/313 за 18.83 сек (+2 от 311).
- Vite build 947 ms.
- Pint + PHPStan passed.
- Pest 266/266 за 28.39 сек (+3 от 263, 1001 assertion).
Реестр v1.70→v1.71 / CLAUDE.md v1.61→v1.62.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>