Финальное code-review вскрыло CRITICAL: dedup-сверка findOnPortal и
manualQueueResolve матчат строки портала по {platform, signal_type,
unique_key}, но listProjects отдавал сырое тело rt-projects-load.
Сырая форма (verified из recon-снапшота 2026-05-19):
- ответ — конверт {projects:[443 строки], tags, users, ...}, НЕ голый массив
→ listProjects возвращал весь dict, findOnPortal итерировал по ключам
конверта (projects/tags/...) вместо строк проектов;
- строка проекта: {id, name:"B<n>_<key>", type:"hosts|calls|sms", content}
— без platform/signal_type/unique_key.
Фикс:
- SupplierPortalClient::listProjects — извлекает body['projects'].
- AjaxProjectChannel::listProjects — нормализует сырые строки в контракт
SupplierProjectChannel: platform <- префикс name "B<n>_", signal_type <-
type (hosts->site/calls->call/sms->sms), unique_key <- content. Сырые
поля сохранены. findOnPortal + manualQueueResolve матчат корректно.
- AjaxProjectChannelTest — тест нормализации против фактической формы
портала (не идеального мока); SupplierPortalClientRtProjectTest —
listProjects против конверта {projects}.
Также (code-review I1): findOnPortal catch — Log::warning проглоченного
исключения, иначе провал дедупа невидим (молчаливый дубль rt-проекта).
Code-review I2 (partial-unique индекс supplier_manual_sync_queue от
дубль-эскалаций при job-retry) и I3 (lockForUpdate в manualQueueResolve) —
follow-up до прод-релиза (эпик гейтится Б-1, не в проде).
Регрессия Pest 973/970/0 / 3 skipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/admin/supplier-integration/manual-queue — pending список (limit 100).
POST /manual-queue/{id}/resolve — оператор пометил, что вручную создал проект
на портале; reconcile через channel->listProjects() по (platform, signal_type,
unique_key), 409 если не найден.
ОТКЛОНЕНИЕ ОТ plan Step 10.3: план писал portal external_id прямо в
projects.supplier_b*_project_id (FK на local supplier_projects.id) — FK
violation. Resolve делает firstOrCreate local supplier_projects row с
verified external_id, в FK пишет local id.
Routes — в группе saas-admin (web.php, EnsureSaasAdmin стаб). Task 10 of 12.
Tests 4/4 (index pending / exclude resolved / resolve match / resolve 409).
Spec §4.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Запас ~3 часа до портального дедлайна 21:00 — эскалация на ярус 2/3
(медленный браузер / ручной оператор) происходит в рабочее время.
RefreshSupplierSessionJob daily — на 15 мин раньше sync (17:45).
Hourly RefreshSupplierSessionJob — без изменений.
Spec §4.7. Task 9 of 12. Tests 2/2 (cron expression + timezone).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Оба job'а инжектят SupplierProjectChannel (DI → FailoverProjectChannel)
вместо прямого SupplierPortalClient. Catch TierEscalatedException +
WindowDeferredException — эскалация/перенос пропускают элемент, не валят job.
SyncSupplierProjectJob (singular): handle переписан — find-or-create local
supplier_projects row, portal-create через channel. ОТКЛОНЕНИЕ ОТ plan Step 8.1:
план писал channel-результат (portal external_id) прямо в projects.supplier_b*_
project_id, но эта колонка — FK на supplier_projects.id (local), не portal id.
Сохранена семантика ensureSupplierProject — job создаёт local row с
supplier_external_id и пишет в FK local id. ensureSupplierProject удалён из
SupplierPortalClient (был единственный consumer — этот job).
SyncSupplierProjectsJob (plural): handle/syncOne принимают channel; create →
createProjectForLiderra, update → updateProjectForLiderra (context-project из
liderraProjects->first() для project_id в очереди яруса 3).
Tests: singular переписан под SupplierProjectChannel mock (6 tests, incl.
idempotency reuse); plural — handle(AjaxProjectChannel) для non-failover
ветки (Http::fake-контракт сохранён). Larastan отложен на T12 (worktree
quirk — гонится в основной копии). Регрессия Pest 966/963/0 / 3 skipped.
Spec §5. Task 8 of 12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
listProjects() матч по (platform, signal_type, unique_key) до create.
Защита от дубля при полу-успехе яруса 1 (create прошёл на портале, но
локальная запись не сохранилась → следующий запуск дублировал бы).
listProjects-сбой проглатывается — ярус-эскалация всё равно покроет.
Spec §4.4 шаг 2, §7. Task 5 of 12. Тесты 7/7 (19 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.
Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET /admin/visit/rt-projects-load?src=none
Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error: HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).
SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
- platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
- signalType site/call/sms → type:hosts/calls/sms
- workdays int[] → string[]
- status active/paused → bool
- + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc
Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.
Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Резервный CSV-канал (Путь 2): отчёт поставщика «Запрос номеров» не
содержит vid -> CSV-recovered лиды имеют vid=NULL. UNIQUE-индекс
idx_supplier_leads_vid_unique сохранён (PostgreSQL NULL != NULL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный текст со
встроенным URL/доменом (B1_заявка carmoney.ru/, B1_Платежи
cabinet.caranga.ru/login, B1_krk-finance.ru/cabinet/auth). Старый
anchored-regex требовал, чтобы вся строка после B1_ была чистым доменом;
такой rest не матчил — классификация sms — B1+sms — DomainException
(chk_supplier_projects_b1_not_for_sms) — 21 реальный лид застрял с error,
0 сделок.
Fix: после двух anchored-проверок (call/site) — fallback-извлечение
домена с латинским TLD из любой позиции строки — signal_type=site,
identifier = извлечённый домен. Реальные sms-имена (B1_TINKOFF) без
точки-домена остаются sms — существующий B1+SMS-тест не затронут.
3 параметризованных теста (carmoney/caranga/krk) + регрессия:
RouteSupplierLeadJobTest 12/12, Supplier+Integration+Webhook 61/61.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX-request 18.05.2026 (п.9):
- ProjectDetailsDrawer (правая панель на /projects) теперь редактирует
signal_identifier для site (домен) и call (телефон 7\d{10}); для sms —
sms_senders+sms_keyword (как раньше).
- Поле «Источник» отображается **только** в карточке проекта (read-only
в drawer сделки на /deals — Task 2 закрыл).
Backend:
- UpdateProjectRequest: condition-based валидация по signal_type из БД
(site domain regex, call 11-digit 7\d{10}; sms — без новых правил)
- ProjectService::update: убран signal_identifier из silent-drop;
$needsResync расширен на signal_identifier → SyncSupplierProjectJob
signal_type остаётся immutable (менять тип проекта — отдельная задача).
Larastan baseline bumped (ProjectsUpdateTest: actingAs 8→12 для 4 новых тестов).
Pest tests/Feature/Plan5/Projects/ProjectsUpdateTest 12/12.
Vitest 33 passes на Project-spec'ах. Build 2.03s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C1: ProjectResource не возвращал regions → edit-диалог/drawer затирали
сохранённые регионы при сохранении. +поле в toArray().
C2: +integration-тест outbound regions[] через полный SyncSupplierProjectsJob::handle().
I1: расскип NewProjectDialog payload-теста (regions в POST).
I2: assert data.regions в ProjectsStore/UpdateTest (ловит C1 на backend-уровне).
I4: docblock — bulkUpdateRegions legacy (region_mask, не влияет на outbound до Plan 6.5).
M1: CHANGELOG v8.22 — исправлен неверный пример регионов (Москва=82).
Регрессия: Pest 905/902/3sk/0, Vitest 104f/884/3sk/0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final review нашёл: HistoricalImportService::loadStatusOverrides и
persistUnknownStatuses запрашивали import_unknown_statuses без явного
where(tenant_id), полагаясь на RLS через SET LOCAL. Но queue worker на prod
работает под crm_supplier_worker — BYPASSRLS-роль (00_create_roles.sql §5),
SET LOCAL не фильтрует → cross-tenant утечка: импорт тенанта A мог подхватить
resolved-маппинг тенанта B и инкрементировать его occurrences.
Добавлен явный where(tenant_id) в обе выборки (конвенция defense-in-depth
00_create_roles.sql:64 — WHERE-фильтры обязательны под BYPASSRLS). +тест
cross-tenant изоляции (red-green verified: без фикса 'Архив' тенанта A
получал status 'closed' из чужого маппинга).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review Task 9 (🟡): добавлен тест защиты show() — пользователь одного
тенанта получает 403 при запросе import_log другого тенанта (покрывает
abort_if defense-in-depth в ImportController::show). phpstan-baseline
регенерирован — инкремент count ложного TestCall-срабатывания (квирк 25).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShouldQueue-job: читает CSV через Storage::disk('local'), парсит через
CsvLeadsParser, импортирует через HistoricalImportService (4 аргумента),
обновляет import_log (pending→processing→done|failed), шлёт
ImportCompletedNotification. RLS через SET LOCAL в каждой транзакции.
tries=1 (идемпотентность на уровне строк, повторный прогон искажает
счётчики — авто-ретрай отключён). Larastan: 0 новых ошибок.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Реализован HistoricalImportService с ImportResult DTO и 7 feature-тестами
(TDD). Идемпотентный upsert через pg_advisory_xact_lock + webhook_dedup_keys;
создание партиций через MonthlyPartitionManager; напоминания; unknown-статусы
с tenant-переопределениями; dry_run режим; historical_import tx без списания
баланса. Попутный fix CarbonImmutable-петли в MonthlyPartitionManager::ensureRange.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Выносит DDL-логику создания месячных RANGE-партиций из команды
PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager.
Сервис используется командой (DRY) и будет использован HistoricalImportService
для партиций под исторические даты CSV.
- MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции
под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы
- MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция
- PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(),
use DB; inject MonthlyPartitionManager через handle()
- Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат)
- Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review Task 1: явный per-table GRANT-блок для import_unknown_statuses
использовал несуществующие роли (crm_app_admin / crm_readonly). Реальные роли —
crm_app_user / crm_admin_user / crm_migrator / crm_audit_writer /
crm_supplier_worker (db/00_create_roles.sql). Блок удалён целиком из
db/02_grants.sql и db/schema.sql: import_unknown_statuses — обычная
tenant-scoped таблица, покрыта umbrella GRANT ... ON ALL TABLES +
ALTER DEFAULT PRIVILEGES (как import_log), явный per-table grant не нужен.
ImportSchemaTest: UNIQUE-тест усилен — проверяет состав колонок
(status_ru, tenant_id), а не только наличие constraint'а типа 'u'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>