Commit Graph

4 Commits

Author SHA1 Message Date
Дмитрий d435101039 fix(supplier): в офлайн-режиме не дёргать поставщика при создании проекта
Прод-инцидент 14.07.2026. В batch-режиме портал при создании проекта слал поставщику
«каркас» с limit=0 и без регионов. Кабинет такой запрос ОТБИВАЕТ ВСЕГДА — снято живьём
с боевого 14.07:

    POST /admin/visit/rt-project-save
    {"status":"Error","message":"Введите limit!"}

Дальше портал считал отказ поломкой: дёргал запасной путь через браузер, тот тоже падал,
и проект уезжал в ручную очередь. Итог на бою: 114 неразобранных записей и 2 ложных
high-инцидента «похоже, кабинет поставщика упал» (08.07 и 14.07). Кабинет при этом жив —
проверено запросом с боевого: отдаёт 140 проектов, сессия рабочая.

Лиды и деньги при этом НЕ терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob
(18:00 МСК) — уже с посчитанными лимитами и регионами. Так доехали 19/19 (07.07), 25/26
(08.07), 1/1 (10.07); «недоехавший» проект №20 у поставщика на деле есть (3 строки,
включены, лимит 1+1+1 = заказ клиента) — пусты лишь поля-ссылки в карточке.

Что сделано: handleBatch больше не ходит к поставщику при создании — слать нечего, дневной
лимит считается на cut-off, а не в момент создания. Идемпотентная привязка уже существующих
строк сохранена. Слать limit>0, чтобы кабинет «принял», НЕЛЬЗЯ: у каркаса нет регионов, и
включённая строка потянет лиды со всей страны за деньги клиента.

Тесты: batch-путь переписан под новое правило (поставщик не зовётся, ручная очередь пуста);
разбор проекта на площадки (site/call → B1+B2+B3, sms+keyword → B2+B3, sms → B3) вынесен в
прямые проверки SupplierProjectGrouping — раньше он проверялся через вызовы createProject.

Прогон: 2453/2458 (единственное падение — ExampleTest/Vite manifest, окружение свежего
worktree, к правке отношения не имеет), phpstan 0, pint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:44:44 +03:00
Дмитрий 7a3d81b3f3 fix(supplier): SyncSupplierProjectJob → pgsql_supplier (BYPASSRLS) — иначе queue-воркер падает 42704
Джоб создания/правки проекта запускается из очереди, где SetTenantContext не
отрабатывает (нет app.current_tenant_id GUC). Под боевой ролью crm_app_user первый
же Project::find() падал SQLSTATE 42704 (unrecognized configuration parameter
app.current_tenant_id) за ~2мс — до контакта с поставщиком: проект у поставщика не
создавался, в UI вечный «Sync pending». На dev не всплывало (postgres superuser
обходит RLS). Единственный supplier-flow джоб, который был на дефолтном подключении.

Фикс: const DB_CONNECTION = 'pgsql_supplier' + все DB-операции через ::on()/
DB::connection() — как у SyncSupplierProjectsJob/DeleteSupplierProjectJob/CsvReconcileJob.

Тесты: SupplierConnectionTest +constant-assert; SyncSupplierProjectJobTest
+поведенческий connection-assert (DB::listen → projects-запросы на pgsql_supplier);
Plan5/SyncSupplierProjectJobTest +SharesSupplierPdo (джоб теперь пишет через
pgsql_supplier → нужен shared PDO под DatabaseTransactions).

Проверено вживую на тест-сервере: проекты 14/15 синхронизированы, 6 доноров у
crm.bp-gr.ru (12742042-44 / 12766120-22), aggregateSyncStatus=ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:49:59 +03:00
Дмитрий be52e4bf55 feat(supplier): wire jobs to FailoverProjectChannel
Оба 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>
2026-05-19 12:55:08 +03:00
Дмитрий 85f8e9e7a0 feat(jobs): Plan 5 Task 4 — SyncSupplierProjectJob full impl + ensureSupplierProject
- SyncSupplierProjectJob: replace stub with full implementation
  (tries=3, backoff=[15,60,300]s; resolvePlatforms uppercase B1/B2/B3;
  buildUniqueKey site/call→signal_identifier, sms B2→sender+keyword, B3→sender;
  column name via strtolower($platform) to match schema snake_case)
- SupplierPortalClient: drop final modifier (Mockery testability);
  add ensureSupplierProject() idempotent lookup-or-create wrapper
- Tests: 6 passing (site/call/sms-with-kw/sms-no-kw/exception/partial-failure);
  DI fix via dispatchJobSync() helper resolving mock from container;
  uppercase platform fixtures matching CHECK constraint B1/B2/B3;
  last_error column absent from schema — partial-failure test uses sync_status only
- phpstan-baseline.neon: add $this->mock() Pest TestCase inference gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:52:51 +03:00