Перенос 7fa811b4 из gitea/main в ветку стройки, чтобы выкат отсюда не вернул баг на
боевой (класс ошибки 08.07 — «затёрло выкатом из устаревшей ветки»).
Суть: в batch-режиме портал слал поставщику «каркас» с limit=0 и без регионов. Кабинет
отбивает такой запрос ВСЕГДА — снято живьём с боевого 14.07:
{"status":"Error","message":"Введите limit!"}. Портал считал отказ поломкой, дёргал
запасной путь через браузер, тот тоже падал → проект уезжал в ручную очередь. Итог на
бою: 114 мусорных записей и 2 ложных high-инцидента «кабинет поставщика упал» (кабинет
при этом жив — отдаёт 140 проектов).
Лиды не терялись: настоящие строки создаёт вечерний SyncSupplierProjectsJob (18:00) с
посчитанными лимитами и регионами. Теперь handleBatch к поставщику при создании не ходит
(слать нечего), идемпотентная привязка существующих строк сохранена.
NB: то же самое для ОНЛАЙН-пути в этой ветке уже сделано («кабинет отклоняет limit=0» —
площадки с нулевой долей не создаются). Правки не пересекаются: там handleOnline, тут
handleBatch.
Тесты: 254/254 (Supplier + Plan5) в этой ветке; в main полный прогон 2453/2458.
NB: LEFTHOOK_EXCLUDE=larastan — статанализ в рабочей копии сломан устаревшим
_ide_helper_models.php (фантомы «Project::aggregateSyncStatus() не существует», хотя метод
есть в Project.php:181). По изменённым файлам статанализ прогнан в чистой песочнице: 0
ошибок. Bypass согласован с владельцем. Follow-up: перегенерировать стаб (--write-mixin).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Джоб создания/правки проекта запускается из очереди, где 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>
Оба 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>