diff --git a/app/app/Console/Commands/PartitionsDropExpired.php b/app/app/Console/Commands/PartitionsDropExpired.php index e5578c42..f3b61f2e 100644 --- a/app/app/Console/Commands/PartitionsDropExpired.php +++ b/app/app/Console/Commands/PartitionsDropExpired.php @@ -176,6 +176,10 @@ class PartitionsDropExpired extends Command */ private function dropPartition(string $partitionName): void { - DB::statement("DROP TABLE IF EXISTS {$partitionName}"); + // DROP требует владения родителем — крутится через pgsql_supplier + // (crm_supplier_worker — член владельца crm_migrator). См. + // MonthlyPartitionManager::DDL_CONNECTION. + DB::connection(MonthlyPartitionManager::DDL_CONNECTION) + ->statement("DROP TABLE IF EXISTS {$partitionName}"); } } diff --git a/app/app/Http/Controllers/Api/AdminIncidentsController.php b/app/app/Http/Controllers/Api/AdminIncidentsController.php index ce9f2e89..d747c575 100644 --- a/app/app/Http/Controllers/Api/AdminIncidentsController.php +++ b/app/app/Http/Controllers/Api/AdminIncidentsController.php @@ -25,6 +25,15 @@ class AdminIncidentsController extends Controller { use ResolvesAdminUserId; + /** + * SaaS-level tables (`incidents_log`, `tenants`, `saas_admin_users`) читаются + * под BYPASSRLS-ролью `crm_supplier_worker`: у дефолтной `crm_app_user` нет + * грантов на `incidents_log` → `permission denied`. Паттерн соответствует + * остальной cross-tenant cron-инфраструктуре (incidents:watch-failures, + * scheduler:check-heartbeats, audit:verify-chains). + */ + private const DB_CONNECTION = 'pgsql_supplier'; + /** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */ public function index(Request $request): JsonResponse { @@ -34,7 +43,7 @@ class AdminIncidentsController extends Controller $limit = max(1, min(500, (int) $request->query('limit', '100'))); $offset = max(0, (int) $request->query('offset', '0')); - $query = DB::table('incidents_log'); + $query = DB::connection(self::DB_CONNECTION)->table('incidents_log'); if ($type !== '') { $query->where('type', $type); @@ -90,7 +99,7 @@ class AdminIncidentsController extends Controller /** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */ public function notifyRkn(Request $request, int $id): JsonResponse { - $row = DB::table('incidents_log')->where('id', $id)->first(); + $row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first(); if ($row === null) { abort(404, 'incident not found'); } @@ -103,8 +112,8 @@ class AdminIncidentsController extends Controller $adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot'); - DB::transaction(function () use ($row, $adminUserId, $request): void { - DB::table('incidents_log')->where('id', $row->id)->update([ + DB::connection(self::DB_CONNECTION)->transaction(function () use ($row, $adminUserId, $request): void { + DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $row->id)->update([ 'rkn_notified_at' => now(), 'updated_at' => now(), ]); @@ -128,7 +137,7 @@ class AdminIncidentsController extends Controller /** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */ public function show(int $id): JsonResponse { - $row = DB::table('incidents_log')->where('id', $id)->first(); + $row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first(); if ($row === null) { abort(404, 'incident not found'); } @@ -139,10 +148,10 @@ class AdminIncidentsController extends Controller $tenants = $tenantIds === [] ? collect() - : DB::table('tenants')->whereIn('id', $tenantIds) + : DB::connection(self::DB_CONNECTION)->table('tenants')->whereIn('id', $tenantIds) ->select(['id', 'organization_name'])->get(); - $admins = DB::table('saas_admin_users') + $admins = DB::connection(self::DB_CONNECTION)->table('saas_admin_users') ->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id])) ->pluck('full_name', 'id'); @@ -236,7 +245,7 @@ class AdminIncidentsController extends Controller */ private function computeSummary(): array { - $base = DB::table('incidents_log'); + $base = DB::connection(self::DB_CONNECTION)->table('incidents_log'); return [ 'open' => (clone $base)->whereNull('resolved_at')->whereNull('detected_at')->count(), diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php index ab6ea1d8..ae52f905 100644 --- a/app/app/Services/MonthlyPartitionManager.php +++ b/app/app/Services/MonthlyPartitionManager.php @@ -24,6 +24,21 @@ use InvalidArgumentException; */ class MonthlyPartitionManager { + /** + * Connection used for partition DDL (CREATE / DROP). + * + * На проде партиционированные родители принадлежат `crm_migrator`; + * `crm_supplier_worker` — член `crm_migrator` (см. db/02_grants.sql), + * поэтому через `pgsql_supplier` создаёт/дропает партиции, а + * дефолтный `crm_app_user` — нет. На dev/тестах `pgsql_supplier` + * фоллбэчит на `postgres` (superuser) — DDL также проходит. + * + * Тесты, триггерящие CREATE/DROP через менеджер, должны подключать + * `Tests\Concerns\SharesSupplierPdo`, иначе DDL уйдёт мимо + * test-транзакции (см. trait doc). + */ + public const DDL_CONNECTION = 'pgsql_supplier'; + /** * Таблицы, партиционированные помесячно. * Ключ → имя таблицы, значение → колонка-ключ партиционирования. @@ -90,7 +105,7 @@ class MonthlyPartitionManager return false; } - DB::statement(sprintf( + DB::connection(self::DDL_CONNECTION)->statement(sprintf( "CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')", $partition, $table, diff --git a/app/tests/Feature/Console/PartitionsDropExpiredTest.php b/app/tests/Feature/Console/PartitionsDropExpiredTest.php index dcdc031d..8b5641e3 100644 --- a/app/tests/Feature/Console/PartitionsDropExpiredTest.php +++ b/app/tests/Feature/Console/PartitionsDropExpiredTest.php @@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); +// ensureMonth/dropPartition теперь идут через pgsql_supplier — нужен shared PDO, +// иначе CREATE/DROP уйдут мимо test-транзакции (см. MonthlyPartitionManager::DDL_CONNECTION). // --------------------------------------------------------------------------- // Guard: check whether auth_log is partitioned. Tests in this file require diff --git a/app/tests/Feature/Import/HistoricalImportServiceTest.php b/app/tests/Feature/Import/HistoricalImportServiceTest.php index 69760982..70d50553 100644 --- a/app/tests/Feature/Import/HistoricalImportServiceTest.php +++ b/app/tests/Feature/Import/HistoricalImportServiceTest.php @@ -12,8 +12,12 @@ use App\Services\Import\CsvLeadsParser; use App\Services\Import\HistoricalImportService; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); +// HistoricalImportService::importBatch вызывает MonthlyPartitionManager::ensureRange, +// которая делает CREATE через pgsql_supplier — нужен shared PDO, иначе DDL уйдёт +// мимо test-транзакции. beforeEach(function (): void { $this->tenant = Tenant::factory()->create(['balance_leads' => 5]); diff --git a/app/tests/Feature/Import/ImportLeadsJobTest.php b/app/tests/Feature/Import/ImportLeadsJobTest.php index 6302eca9..237c6311 100644 --- a/app/tests/Feature/Import/ImportLeadsJobTest.php +++ b/app/tests/Feature/Import/ImportLeadsJobTest.php @@ -14,8 +14,11 @@ use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); +// ImportLeadsJob запускает HistoricalImportService → MonthlyPartitionManager → +// CREATE через pgsql_supplier. Нужен shared PDO. beforeEach(function (): void { $this->tenant = Tenant::factory()->create(); diff --git a/app/tests/Feature/Import/MonthlyPartitionManagerTest.php b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php index 5691bf07..5de52cf0 100644 --- a/app/tests/Feature/Import/MonthlyPartitionManagerTest.php +++ b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php @@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); +// ensureMonth теперь делает CREATE через pgsql_supplier (см. MonthlyPartitionManager::DDL_CONNECTION). +// Без SharesSupplierPdo DDL уйдёт мимо test-транзакции и партиции протечь в test DB. function partitionExists(string $name): bool { diff --git a/app/tests/Feature/Pd/DealImportPdLogTest.php b/app/tests/Feature/Pd/DealImportPdLogTest.php index 91fe558d..f76e0fe2 100644 --- a/app/tests/Feature/Pd/DealImportPdLogTest.php +++ b/app/tests/Feature/Pd/DealImportPdLogTest.php @@ -14,8 +14,11 @@ use App\Services\Import\CsvLeadsParser; use App\Services\Import\HistoricalImportService; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; +use Tests\Concerns\SharesSupplierPdo; -uses(DatabaseTransactions::class); +uses(DatabaseTransactions::class, SharesSupplierPdo::class); +// HistoricalImportService → MonthlyPartitionManager → CREATE через pgsql_supplier +// (см. MonthlyPartitionManager::DDL_CONNECTION). Нужен shared PDO. it('writes pd_processing_log created on historical import for each new deal', function () { $tenant = Tenant::factory()->create();