user(). * * O-perf-05: streaming устраняет memory pressure. OpenSpout пишет * в php://output постранично через Writer + Row::fromValues и chunkById(500) * по сделкам — пик памяти O(1) от размера экспорта. */ class DealExportController extends Controller { /** Заголовки — общие для CSV и XLSX. */ private const HEADERS = ['Телефон', 'Источник', 'Город', 'Статус', 'Комментарий', 'Поставлен']; /** signal_type → русская метка для колонки «Источник». */ private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС']; public function export(Request $request): StreamedResponse { $validated = $request->validate([ 'received_from' => 'nullable|date', 'received_to' => 'nullable|date', 'format' => 'nullable|string|in:csv,xlsx', ]); $tenantId = (int) $request->user()->tenant_id; $format = $validated['format'] ?? 'csv'; $from = isset($validated['received_from']) && $validated['received_from'] !== '' ? Carbon::parse($validated['received_from'])->startOfDay() : null; $to = isset($validated['received_to']) && $validated['received_to'] !== '' ? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null; app(PdAuditLogger::class)->record( action: 'exported', subjectType: 'lead', subjectId: null, purpose: 'deals_export_'.$format, tenantId: $tenantId, actorTenantUserId: (int) $request->user()->id, actorAdminUserId: null, ip: $request->ip(), ); $filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format; $headers = $format === 'xlsx' ? [ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ] : [ 'Content-Type' => 'text/csv; charset=utf-8', 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ]; return new StreamedResponse(function () use ($tenantId, $format, $from, $to) { // RLS-контекст должен быть установлен внутри транзакции на момент // фактического SELECT. StreamedResponse callback вызывается уже // после Laravel-response pipeline'а, поэтому открываем транзакцию // прямо здесь. DB::transaction(function () use ($tenantId, $format, $from, $to) { DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); $statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug'); $writer = $this->openWriter($format); $writer->openToFile('php://output'); // Заголовок: для XLSX — bold через Style + имя листа «Сделки». // Для CSV — OpenSpout сам пишет UTF-8 BOM (SHOULD_ADD_BOM=true // в Options) и `;`-разделитель из конструктора. if ($format === 'xlsx') { /** @var XlsxWriter $writer */ $writer->getCurrentSheet()->setName('Сделки'); $writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true))); } else { $writer->addRow(Row::fromValues(self::HEADERS)); } $query = Deal::query() ->where('tenant_id', $tenantId) ->with('project:id,name,signal_type') ->orderByDesc('received_at'); if ($from !== null) { $query->where('received_at', '>=', $from); } if ($to !== null) { $query->where('received_at', '<', $to); } // chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique), // корректно для чанкинга даже при партиционированной PK (id, received_at). $query->chunkById(500, function ($deals) use ($writer, $statusNames) { foreach ($deals as $deal) { /** @var Deal $deal */ $signal = $deal->project?->signal_type; $source = trim(($deal->project?->name ?? '—').' · ' .(self::SIGNAL_LABELS[$signal] ?? '—')); $writer->addRow(Row::fromValues([ (string) $deal->phone, $source, (string) ($deal->city ?? ''), (string) ($statusNames[$deal->status] ?? $deal->status), (string) ($deal->comment ?? ''), $deal->received_at?->toDateTimeString() ?? '', ])); } }, 'id'); $writer->close(); }); }, 200, $headers); } private function openWriter(string $format): CsvWriter|XlsxWriter { if ($format === 'xlsx') { return new XlsxWriter; } // CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль). return new CsvWriter(new CsvOptions( FIELD_DELIMITER: ';', FIELD_ENCLOSURE: '"', SHOULD_ADD_BOM: true, )); } }