BUG-027: Кнопка «Экспорт CSV» на /admin/payroll собирает broken URL → 422

Описание

На странице /admin/payroll кнопка «Экспорт CSV» при клике формирует невалидный URL:

GET /api/v1/admin/payroll/?store_id=...&period=2026-05/export

Бэк возвращает 422 — query-string помещён в позицию {id} path-сегмента, а /export приклеен к концу query. Файл не скачивается.

Зафиксировано как F91 в test-base/findings.md (Critical, session-2026-05-12).

Шаги воспроизведения

  1. Зайти на https://admin.nirbi.ru/admin/payroll
  2. Выбрать период с ведомостями (или сначала «Расчёт»)
  3. Нажать «Экспорт CSV»
  4. Devtools → Network: видим GET /api/v1/admin/payroll/?store_id=&period=2026-05/export → 422

Ожидаемое поведение

Скачивается CSV (UTF-8 + BOM, заголовки «ФИО, Роль, Плановые часы, Фактические часы, Перерывы, Чистое время, Формула, Начислено, Статус») — точно как генерирует PayrollService.export:263-307.

Фактическое поведение

422, файл не скачивается, toast «Ошибка при экспорте».

Корневая причина

erp-admin/web/src/pages/payroll/PayrollPage.tsx:211:

const blob = await exportPayrollCsv(`?store_id=${storeId || ""}&period=${period}`);

Передаёт query-string в позицию id функции exportPayrollCsv(id), которая строит ${API}/${id}/export → итоговый URL ломается.

Бэк работает по спеке

  • API.mdGET /payroll/{id}/export — per-record экспорт (User Service/API.md:98)
  • PayrollController:55@GetMapping("/{id}/export") корректно
  • PayrollService.export:263-307 — реальный CSV-генератор (UTF-8 + BOM, russian-headers, escaped)
  • BFF bff/src/routes/payroll.ts:75-77 — корректно проксирует с binary headers
  • web/src/api/payroll.ts:45-46 — функция exportPayrollCsv(id) правильно строит URL

Проблема только в одной точке вызоваPayrollPage.tsx:211.

Природа endpoint’а (важное уточнение)

GET /payroll/{id}/export по факту bulk per-store-period, не per-record: PayrollService.export:274-276 берёт store_id и period_start из record по idfindByFranchiseIdAndStoreIdAndPeriodStart(...) → пишет в CSV все ведомости этой ТТ за этот период. То есть достаточно любого id из выборки, бэк сам определит множество.

Multi-store-export endpoint не поддерживается — пользователь обязан выбрать конкретную ТТ.

Скоп фикса (v2)

  • items.length === 0 → кнопка disabled
  • !storeId («Все ТТ» в фильтре) → toast «Экспорт работает по одной ТТ за раз — выберите ТТ в фильтре»
  • ТТ выбрана + items.length ≥ 1exportPayrollCsv(items[0].id) → бэк отдаёт CSV всех ведомостей этой ТТ+периода

История фикса

  • v1 (b9cd298): ошибочно считал endpoint per-record. Логика: items.length===1 → экспорт, иначе toast «сузьте фильтр». Работало только если на ТТ ровно 1 сотрудник.
  • v2 (bc54ba2): корректная логика после повторного чтения PayrollService.export. Тост честно сообщает что bulk = per-store, фильтр по ТТ обязателен. Подтверждено регрессом на проде: реальный CSV скачивается со всеми сотрудниками выбранной ТТ.

Out of scope (на будущее)

  • Per-row кнопки в каждой строке таблицы — лучший UX, но не блокер
  • Bulk-export endpoint GET /payroll/export?store_id&period — требует новый бэк-endpoint, спека-апдейт. Полезно если нужен «вся ведомость за период одним файлом». Не реализован.