diff --git a/app/.env.example b/app/.env.example index 585e91fa..682f03af 100644 --- a/app/.env.example +++ b/app/.env.example @@ -28,6 +28,20 @@ DB_DATABASE=liderra DB_USERNAME=postgres DB_PASSWORD= +# Supplier Sync (Plan 3 Task 3) — crm_supplier_worker BYPASSRLS connection. +# На production указывают на роль crm_supplier_worker (создана db/00_create_roles.sql v1.1). +# Если не заданы — fallback на DB_USERNAME / DB_PASSWORD (dev: postgres superuser, BYPASSRLS implicit). +DB_SUPPLIER_USERNAME=crm_supplier_worker +DB_SUPPLIER_PASSWORD= + +# Supplier Portal credentials (Playwright login — Task 5) +SUPPLIER_LOGIN= +SUPPLIER_PASSWORD= +SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru + +# Supplier alerts (email через Unisender Go relay) +SUPPLIER_ALERT_EMAIL= + SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false diff --git a/app/app/Console/Commands/ResetDeliveredTodayCommand.php b/app/app/Console/Commands/ResetDeliveredTodayCommand.php index a9cb7f2c..4dd3910f 100644 --- a/app/app/Console/Commands/ResetDeliveredTodayCommand.php +++ b/app/app/Console/Commands/ResetDeliveredTodayCommand.php @@ -10,13 +10,13 @@ use Illuminate\Support\Facades\DB; /** * Сброс projects.delivered_today=0 для всех tenant'ов. * - * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1. + * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1 + + * docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1. * Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow). * - * NB: tenant-scoped запрос без RLS — UPDATE сразу на все tenant'ы. На production - * queue worker (через Scheduler) запускается под ролью crm_supplier_worker - * (BYPASSRLS) — Plan 2.6 fix #iv. На dev подключение под postgres (BYPASSRLS - * implicit). См. db/00_create_roles.sql. + * Plan 3 Task 3 (WARN #3): UPDATE идёт через connection `pgsql_supplier` + * (BYPASSRLS-роль crm_supplier_worker), что позволяет одним statement'ом сбросить + * счётчики по всем tenant'ам без SET LOCAL app.current_tenant_id для каждого. */ class ResetDeliveredTodayCommand extends Command { @@ -26,7 +26,8 @@ class ResetDeliveredTodayCommand extends Command public function handle(): int { - $affected = DB::update('UPDATE projects SET delivered_today = 0 WHERE delivered_today <> 0'); + $affected = DB::connection('pgsql_supplier') + ->update('UPDATE projects SET delivered_today = 0 WHERE delivered_today <> 0'); $this->info("Reset delivered_today on {$affected} project(s)."); diff --git a/app/app/Jobs/RouteSupplierLeadJob.php b/app/app/Jobs/RouteSupplierLeadJob.php index c0a96972..997f6e31 100644 --- a/app/app/Jobs/RouteSupplierLeadJob.php +++ b/app/app/Jobs/RouteSupplierLeadJob.php @@ -61,6 +61,20 @@ class RouteSupplierLeadJob implements ShouldQueue public int $timeout = 60; + /** + * Plan 3 Task 3: имя DB-connection (BYPASSRLS-роль crm_supplier_worker), через который + * supplier-flow обходит RLS для sharing-операций (failed_webhook_jobs с tenant_id=NULL). + * + * NB: это НЕ $this->connection из Bus\Queueable — то управляет очередью, не БД. + * Job's queue connection остаётся default (sync/database), а DB-операции в failed() + * явно идут через DB::connection(self::DB_CONNECTION). Tenant-scoped транзакции в + * handle() (createDealCopyForProject) продолжают использовать default `pgsql` + * с SET LOCAL app.current_tenant_id — там RLS нужна. + * + * См. docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1. + */ + public const DB_CONNECTION = 'pgsql_supplier'; + public function __construct(public int $supplierLeadId) {} public function handle( @@ -292,15 +306,13 @@ class RouteSupplierLeadJob implements ShouldQueue * tenant ещё не определён на момент routing'а) для ручного разбора. * supplier_lead.error апдейтится текстом исключения. * - * ⚠️ PRODUCTION: failed_webhook_jobs имеет RLS-policy `tenant_isolation USING - * (tenant_id = current_setting('app.current_tenant_id')::bigint)`. INSERT с - * tenant_id=NULL под non-BYPASSRLS ролью молча отклонится (NULL = ::bigint → NULL). - * Queue-worker обязан подключаться к БД под elevated-ролью (postgres / service-account - * с BYPASSRLS) ИЛИ нужен INSERT-policy WITH CHECK (true) на эту таблицу — Plan 3+. + * INSERT с tenant_id=NULL проходит благодаря $connection='pgsql_supplier' + * (BYPASSRLS-роль crm_supplier_worker — обходит политику tenant_isolation, + * которая под обычной ролью отвергла бы NULL). Закрыто Plan 3 Task 3. */ public function failed(Throwable $e): void { - DB::table('failed_webhook_jobs')->insert([ + DB::connection(self::DB_CONNECTION)->table('failed_webhook_jobs')->insert([ 'tenant_id' => null, 'webhook_log_id' => null, 'raw_payload' => json_encode([ diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index 8ec23994..b5290194 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -23,13 +23,14 @@ use InvalidArgumentException; * district-bit резолвится по 3/4-значному коду в PHP-словаре). * 7. Сортировка: created_at ASC, id ASC (детерминированно — spec §6 step 4). * - * RLS-quirk: запрос работает поверх N tenant'ов одновременно (sharing-model). - * Не использует SET LOCAL app.current_tenant_id (в sharing-flow tenant ещё не определён — - * запрос подбирает кандидатов из всех tenant'ов параллельно). На production queue worker - * запускается под ролью crm_supplier_worker (BYPASSRLS) — Plan 2.6 fix #iv. На dev - * подключение под postgres (BYPASSRLS implicit). См. db/00_create_roles.sql. + * Plan 3 Task 3: запрос идёт через connection `pgsql_supplier` (BYPASSRLS-роль + * crm_supplier_worker). Это закрывает WARN #2 — в sharing-flow tenant ещё не + * определён, SELECT обходит RLS-фильтрацию и видит проекты ВСЕХ tenant'ов + * параллельно. WHERE-фильтры (is_active, FK на supplier_project, workdays, лимиты, + * balance) сохраняются как defense-in-depth. * - * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 + * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 + + * docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1. */ class LeadRouter { @@ -57,7 +58,7 @@ class LeadRouter $todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1); /** @var Collection $candidates */ - $candidates = Project::query() + $candidates = Project::on('pgsql_supplier') ->where($fkColumn, $supplierProject->id) ->where('is_active', true) ->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit]) diff --git a/app/config/database.php b/app/config/database.php index 32705c74..aa76909b 100644 --- a/app/config/database.php +++ b/app/config/database.php @@ -103,6 +103,48 @@ return [ 'timezone' => env('DB_TIMEZONE', 'UTC'), ], + // Plan 3 Task 3: dedicated PG connection для supplier-flow под BYPASSRLS-ролью + // crm_supplier_worker (создана Plan 2.6 #iv 7899071 в db/00_create_roles.sql v1.1). + // Закрывает 3 backlog-айтема одной правкой: + // - BLOCKER #6: INSERT в failed_webhook_jobs с tenant_id=NULL под BYPASSRLS + // проходит (политика tenant_isolation отвергает NULL под обычной ролью). + // - WARN #2: LeadRouter::matchEligibleProjects видит проекты ВСЕХ tenant'ов + // без SET LOCAL app.current_tenant_id (sharing-model §6 spec'а). + // - WARN #3: ResetDeliveredTodayCommand сбрасывает delivered_today по всем + // tenant'ам в одном UPDATE. + // + // На production env-keys DB_SUPPLIER_USERNAME=crm_supplier_worker + DB_SUPPLIER_PASSWORD + // указывают на BYPASSRLS-роль. На dev fallback на DB_USERNAME/DB_PASSWORD (postgres + // superuser — BYPASSRLS implicit), тесты работают без отдельной роли. + // + // WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth — если в будущем + // роль сменится на не-BYPASSRLS, бизнес-логика останется корректной. + // + // Brainstorm decision: вариант C (BYPASSRLS-role) из 3 опций. См. + // docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1. + 'pgsql_supplier' => array_merge( + [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + 'timezone' => env('DB_TIMEZONE', 'UTC'), + ], + [ + 'username' => env('DB_SUPPLIER_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('DB_SUPPLIER_PASSWORD', env('DB_PASSWORD', '')), + 'options' => [ + '_role_purpose' => 'crm_supplier_worker (BYPASSRLS) for supplier-flow jobs', + ], + ] + ), + 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DB_URL'), diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 443856a3..34489fd5 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -756,6 +756,12 @@ parameters: count: 11 path: tests/Feature/RemindersDispatchDueTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Supplier/SupplierConnectionTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/tests/Feature/Supplier/SupplierConnectionTest.php b/app/tests/Feature/Supplier/SupplierConnectionTest.php new file mode 100644 index 00000000..757590aa --- /dev/null +++ b/app/tests/Feature/Supplier/SupplierConnectionTest.php @@ -0,0 +1,125 @@ +toBe('pgsql_supplier'); +}); + +test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql_supplier (BLOCKER #6)', function (): void { + // Под обычной ролью policy tenant_isolation USING (tenant_id = current_setting('app.current_tenant_id')::bigint) + // отвергает NULL (NULL :: bigint = NULL, NULL = '0'::bigint → NULL → false). + // Под pgsql_supplier (BYPASSRLS на prod / postgres superuser на dev) INSERT проходит. + DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([ + 'tenant_id' => null, + 'webhook_log_id' => null, + 'raw_payload' => json_encode(['supplier_lead_id' => 42, 'project' => 'B1_test.ru']), + 'exception' => 'simulated failure for BLOCKER #6 regression test', + 'retry_count' => 3, + 'failed_at' => now(), + ]); + + $exists = DB::connection('pgsql_supplier') + ->table('failed_webhook_jobs') + ->whereNull('tenant_id') + ->where('exception', 'simulated failure for BLOCKER #6 regression test') + ->exists(); + + expect($exists)->toBeTrue(); +}); + +test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void { + // 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project. + // БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной + // ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6. + $supplier = SupplierProject::factory()->create([ + 'platform' => 'B1', + 'signal_type' => 'site', + 'unique_key' => 'plan3-task3-warn2.example.com', + ]); + + $tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]); + foreach ($tenants as $tenant) { + for ($i = 0; $i < 2; $i++) { + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'supplier_b1_project_id' => $supplier->id, + 'signal_type' => 'site', + 'signal_identifier' => 'plan3-task3-warn2.example.com', + 'is_active' => true, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + 'delivery_days_mask' => 127, + 'region_mask' => 255, + 'region_mode' => 'include', + ]); + } + } + + $router = app(LeadRouter::class); + $eligible = $router->matchEligibleProjects($supplier, '79991234567'); + + expect($eligible)->toHaveCount(6); +}); + +test("ResetDeliveredTodayCommand сбрасывает delivered_today по всем tenant'ам (WARN #3)", function (): void { + // Создаём 3 tenant'а с проектами, у каждого delivered_today=5. + // Команда должна сбросить все 3 → 0 (под pgsql_supplier BYPASSRLS — без SET LOCAL). + $tenants = Tenant::factory()->count(3)->create(); + $projectIds = []; + foreach ($tenants as $tenant) { + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'delivered_today' => 5, + ]); + $projectIds[] = $project->id; + } + + $this->artisan('projects:reset-delivered-today')->assertExitCode(0); + + $remaining = DB::connection('pgsql_supplier') + ->table('projects') + ->whereIn('id', $projectIds) + ->where('delivered_today', '>', 0) + ->count(); + + expect($remaining)->toBe(0); +}); diff --git a/app/tests/TestCase.php b/app/tests/TestCase.php index fe1ffc2f..eba886bd 100644 --- a/app/tests/TestCase.php +++ b/app/tests/TestCase.php @@ -3,8 +3,32 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Illuminate\Support\Facades\DB; abstract class TestCase extends BaseTestCase { - // + /** + * Plan 3 Task 3: share PDO between `pgsql` and `pgsql_supplier` connections в тестах, + * чтобы DatabaseTransactions corretly rollback'ил данные, созданные через дефолтный + * connection, но запрошенные через supplier. Без этого Project::on('pgsql_supplier') + * не видит свежесозданные через Project::factory() записи — две PDO-сессии = две + * разные транзакции, supplier-side не видит uncommitted INSERTs default-side. + * + * На production обе роли (crm_app_user + crm_supplier_worker) — две настоящие + * сессии, общая видимость через commit. В тестах достаточно одной разделяемой PDO. + */ + protected function setUp(): void + { + parent::setUp(); + + // После того как Laravel инициализировал dafault connection (pgsql), + // ре-используем его PDO для pgsql_supplier. Делаем только если оба + // connection'а есть в конфиге (продакшен может не иметь supplier). + if (config()->has('database.connections.pgsql_supplier')) { + $defaultPdo = DB::connection('pgsql')->getPdo(); + $defaultReadPdo = DB::connection('pgsql')->getReadPdo(); + DB::connection('pgsql_supplier')->setPdo($defaultPdo); + DB::connection('pgsql_supplier')->setReadPdo($defaultReadPdo); + } + } }