143 lines
6.6 KiB
PHP
143 lines
6.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Deal;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use OpenSpout\Common\Entity\Row;
|
||
use OpenSpout\Common\Entity\Style\Style;
|
||
use OpenSpout\Writer\CSV\Options as CsvOptions;
|
||
use OpenSpout\Writer\CSV\Writer as CsvWriter;
|
||
use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
|
||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||
|
||
/**
|
||
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
|
||
*
|
||
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
|
||
* (received_at), не по списку id. Окно задаётся received_from/received_to;
|
||
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
|
||
* страницы (без чекбокса и без «Напоминание» — экспорт = дамп лидов).
|
||
*
|
||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
|
||
*
|
||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->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;
|
||
|
||
$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,
|
||
));
|
||
}
|
||
}
|