Files
portal/app/app/Http/Controllers/Api/DealExportController.php
T
Дмитрий 1ed82b6b36 fix: сутки по всему порталу считаются с московской полуночи, а не с трёх ночи
Продолжение правки отдела продаж от 02.08: тот же класс ошибки найден ещё
в тринадцати местах. Граница периода, посчитанная по Москве, уходит в базу
надписью без смещения и читается как гринвичская — сутки съезжают на три часа.

У ошибки оказалось ДВА вкуса, и поиск из промта находил только первый:
  1. граница по Москве, отданная как есть (дашборд клиента, списания,
     расход на рекламу, карточка клиента у отдела продаж, сводка админа);
  2. граница ВООБЩЕ без Москвы - now()->startOfDay() и Carbon::today();
     пояс приложения гринвичский, значит день начинался в 03:00 МСК
     (список лидов и выгрузка, биллинг, карточка клиента у админа,
     посетители, ответ бота клиенту, сверка CSV, снимок рекламной кампании).

Правило названо в одном месте - App\Support\MskBoundary: instant() отдаёт
границу мгновением, dayAfter() - первое мгновение после дня (полуинтервал,
чтобы не терять последнюю секунду). Календарные даты и счёт дней НЕ трогали:
там нужен именно московский календарь.

Заодно в списаниях клиента убрана вторая копия условий периода - выгрузка
CSV фильтровала по своей копии, и та уже разъехалась с общей.

Проверка: tests/Feature/NightBoundaryMskTest.php - девять проверок с часами,
замороженными на 00:30 МСК. До правки девять из девяти красные, после - зелёные.
Две из них поначалу проходили и на сломанном коде: в 00:30 МСК гринвичский день
это ещё вчерашний, и ночное событие случайно попадало в окно; добавлено второе
событие "вчера днём", которое обязано остаться за бортом.

Три чужие проверки закрепляли старую ошибку и поправлены:
  - DealIndexTest "конец дня" клал заявку в 23:30 по Гринвичу - по московскому
    календарю это уже 02:30 следующего дня;
  - ClientFactsTest и GuardCardContractTest строят заявки от now() и ночью
    краснеют сами - часы заморожены на 12:00 МСК с возвратом в afterEach.

Статанализ: 0. Форматтер: чисто.
2026-08-02 10:18:45 +03:00

162 lines
7.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\Pd\PdAuditLogger;
use App\Support\CsvFormulaGuard;
use App\Support\SupplierProjectName;
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';
// Даты фильтра — московские сутки, в запрос мгновениями (App\Support\MskBoundary),
// как и в списке лидов: иначе выгрузка и список расходились бы на три часа.
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
? Carbon::parse($validated['received_from'], 'Europe/Moscow')->startOfDay()->utc() : null;
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
? Carbon::parse($validated['received_to'], 'Europe/Moscow')->addDay()->startOfDay()->utc() : 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((SupplierProjectName::strip($deal->project?->name) ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
// F-CSV: свободный текст (телефон/источник/город/статус/
// комментарий) экранируем от formula-инъекции. Дата —
// системная, не экранируется.
$writer->addRow(Row::fromValues([
CsvFormulaGuard::neutralize((string) $deal->phone),
CsvFormulaGuard::neutralize($source),
CsvFormulaGuard::neutralize((string) ($deal->city ?? '')),
CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)),
CsvFormulaGuard::neutralize((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,
));
}
}