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).
Шаги воспроизведения
- Зайти на
https://admin.nirbi.ru/admin/payroll - Выбрать период с ведомостями (или сначала «Расчёт»)
- Нажать «Экспорт CSV»
- 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.md→GET /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 по id →
findByFranchiseIdAndStoreIdAndPeriodStart(...) → пишет в CSV все ведомости
этой ТТ за этот период. То есть достаточно любого id из выборки, бэк сам
определит множество.
Multi-store-export endpoint не поддерживается — пользователь обязан выбрать конкретную ТТ.
Скоп фикса (v2)
items.length === 0→ кнопкаdisabled!storeId(«Все ТТ» в фильтре) → toast «Экспорт работает по одной ТТ за раз — выберите ТТ в фильтре»- ТТ выбрана +
items.length ≥ 1→exportPayrollCsv(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, спека-апдейт. Полезно если нужен «вся ведомость за период одним файлом». Не реализован.