447ef593fa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
132 lines
5.7 KiB
PHP
132 lines
5.7 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\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;
|
||
|
||
/**
|
||
* Export сделок в CSV / XLSX через OpenSpout streaming.
|
||
*
|
||
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
|
||
*
|
||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||
*
|
||
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
|
||
* полный объект .xlsx в памяти (для 10K сделок ≈ 100+ MB). OpenSpout пишет
|
||
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
|
||
* по сделкам — пик памяти O(1) от размера экспорта.
|
||
*
|
||
* API контракт сохранён:
|
||
* POST /api/deals/export {ids[], format?: csv|xlsx}
|
||
* Headers Content-Type / Content-Disposition без изменений.
|
||
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
|
||
* XLSX: bold-header + auto-size columns.
|
||
*
|
||
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
|
||
* отфильтрует where(tenant_id) defense-in-depth.
|
||
*/
|
||
class DealExportController extends Controller
|
||
{
|
||
/** Заголовки таблицы — общие для CSV и XLSX. */
|
||
private const HEADERS = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
|
||
|
||
public function export(Request $request): StreamedResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'ids' => 'required|array|min:1|max:10000',
|
||
'ids.*' => 'integer|min:1',
|
||
'format' => 'nullable|string|in:csv,xlsx',
|
||
]);
|
||
|
||
$tenantId = (int) $request->user()->tenant_id;
|
||
|
||
$format = $validated['format'] ?? 'csv';
|
||
$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 ($validated, $tenantId, $format) {
|
||
// RLS-контекст должен быть установлен внутри транзакции на момент
|
||
// фактического SELECT. StreamedResponse callback вызывается уже
|
||
// после Laravel-response pipeline'а, поэтому открываем транзакцию
|
||
// прямо здесь.
|
||
DB::transaction(function () use ($validated, $tenantId, $format) {
|
||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||
|
||
$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('Сделки');
|
||
$headerStyle = (new Style)->withFontBold(true);
|
||
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
|
||
} else {
|
||
$writer->addRow(Row::fromValues(self::HEADERS));
|
||
}
|
||
|
||
// chunkById(500) — keyset-friendly; в нашем DealsView это
|
||
// редкий тяжёлый action, экспортировать могут до 10K id.
|
||
Deal::query()
|
||
->where('tenant_id', $tenantId)
|
||
->whereIn('id', $validated['ids'])
|
||
->orderBy('id')
|
||
->chunkById(500, function ($deals) use ($writer) {
|
||
foreach ($deals as $deal) {
|
||
/** @var Deal $deal */
|
||
$writer->addRow(Row::fromValues([
|
||
$deal->id,
|
||
(string) ($deal->contact_name ?? ''),
|
||
(string) $deal->phone,
|
||
(string) $deal->status,
|
||
$deal->project_id,
|
||
$deal->manager_id ?? '',
|
||
$deal->received_at->toDateTimeString(),
|
||
]));
|
||
}
|
||
});
|
||
|
||
$writer->close();
|
||
});
|
||
}, 200, $headers);
|
||
}
|
||
|
||
private function openWriter(string $format): CsvWriter|XlsxWriter
|
||
{
|
||
if ($format === 'xlsx') {
|
||
return new XlsxWriter;
|
||
}
|
||
|
||
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
|
||
$options = new CsvOptions(
|
||
FIELD_DELIMITER: ';',
|
||
FIELD_ENCLOSURE: '"',
|
||
SHOULD_ADD_BOM: true,
|
||
);
|
||
|
||
return new CsvWriter($options);
|
||
}
|
||
}
|