BUG-016: 500 при создании кассира
Описание
При попытке создать сотрудника с ролью cashier через админку возвращается 500 Internal Server Error. Создание сотрудников с другими ролями не проверялось — возможно, проблема затрагивает и другие роли.
Шаги воспроизведения
- Открыть админку → Сотрудники → Создать
- Заполнить форму:
- Имя: Иван
- Фамилия: Куликов
- Email: markin1999981@gmail.com
- Пароль: 12344321sS
- Телефон: +79000870817
- PIN: 1234
- Роль: cashier
- Торговая точка:
9051b882-371b-412d-a88a-c2a5d357152c
- Нажать “Сохранить”
Запрос
POST https://erp-test.nirbi.ru/api/v1/admin/employees
{
"first_name": "Иван",
"last_name": "Куликов",
"email": "markin1999981@gmail.com",
"password": "12344321sS",
"phone": "+79000870817",
"pin_code": "1234",
"role": "cashier",
"store_ids": ["9051b882-371b-412d-a88a-c2a5d357152c"]
}Ответ
Status: 500 Internal Server Error
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Internal server error"
}
}Ожидаемое поведение
Сотрудник создаётся, возвращается 201 Created с данными нового сотрудника.
Фактическое поведение
500 Internal Server Error без деталей. Нужно проверить логи User Service на тестовом VPS.
Затронутые сервисы
| Сервис | Что проверить |
|---|---|
| User Service | Логи: docker logs erp-user-service. Вероятные причины: NPE при валидации PIN, проблема с store_ids validation, ошибка bcrypt, constraint violation в БД |
| Admin BFF | Проксирует запрос — маловероятно что проблема здесь |
Анализ кода
Проанализирован полный flow создания сотрудника: EmployeeController.create() → EmployeeService.create().
Цепочка вызовов при создании кассира с PIN:
validateCreateAccess(user)— проверка роли вызывающего (franchise/franchisee)validateRoleEscalation(user, "cashier")— franchise(4) > cashier(1) ✓existsByFranchiseIdAndEmail()— проверка дубликата email- Валидация store_ids:
storeIds.size() != 1— кассиру нужно ровно 1 ТТ storeServiceClient.validateStoreIds(storeIds, franchiseId)— вызов Store ServicevalidatePinUniquenessPerStore(pin, storeIds, null)— проверка PINemployeeRepository.save(employee)— сохранениеsaveStoreAssignments()→employeeStoreRepository.saveAll()fetchStoreInfos()→storeServiceClient.getStoreNamesByIds()
Вероятные причины 500 (в порядке вероятности)
1. Store Service validateStoreIds вернул false → но это не 500
На строке 122: если validateStoreIds возвращает false — будет 422 INVALID_STORE_IDS, а не 500. Но если Store Service вернул unexpected response format или connection refused — catch в StoreServiceClient.validateStoreIds() ловит RestClientException и возвращает false. Это приведёт к 422, не к 500.
2. validateStoreIds выбрасывает необработанное исключение (ВЕРОЯТНО)
StoreServiceClient.validateStoreIds() делает POST к /internal/stores/validate. Если Store Service не реализовал этот endpoint, он может вернуть 404/405, что RestTemplate преобразует в HttpClientErrorException — это наследник RestClientException, и catch его ловит. Ок, тогда вернёт false → 422.
3. PIN validation падает с NPE (НАИБОЛЕЕ ВЕРОЯТНО ⚠️)
validatePinUniquenessPerStore() (строка 446-458):
- Вызывает
employeeStoreRepository.findActiveWithPinByStoreId(storeId)— кастомный JPQL-запрос с JOIN на Employee - Итерирует результат, для каждого вызывает
employeeRepository.findById() - Затем
PASSWORD_ENCODER.matches(pin, existing.getPinHash()) - Если
findActiveWithPinByStoreIdвозвращаетEmployeeStoreсemployeeId, ноfindByIdвозвращаетnull(orphan record) →existingбудетnull, но.getPinHash()не вызовется из-заexisting != nullcheck. Ок, safe. - Но:
findActiveWithPinByStoreIdиспользуетJOIN Employee e ON e.id = es.employeeId— значит только записи с существующим employee попадут. Safe.
4. DB constraint violation при save (ВЕРОЯТНО ⚠️)
employeeRepository.save(employee) на строке 153 — если в БД есть constraint, который не проверен в коде:
uq_employees_email— проверяется выше. Ок.- Но если между проверкой и вставкой другой запрос создал сотрудника с тем же email — race condition →
DataIntegrityViolationException. Этот exception НЕ ловится ни вEmployeeService, ни вGlobalExceptionHandler(там толькоApiException,MethodArgumentNotValidException,MaxUploadSizeExceededException, и общийException).
Общий Exception handler (строка 50-55 GlobalExceptionHandler) ловит всё и возвращает INTERNAL_ERROR + 500. Значит любой uncaught exception = 500 INTERNAL_ERROR.
5. fetchStoreInfos после создания падает (ВОЗМОЖНО)
На строке 160: после save вызывается fetchStoreInfos(employee.getId()) → storeServiceClient.getStoreNamesByIds(). Если Store Service вернёт unexpected response → catch возвращает empty map. Safe.
Наиболее вероятный сценарий
Store Service endpoint /internal/stores/validate не существует или возвращает неожиданный формат.
StoreServiceClient.validateStoreIds() делает restTemplate.exchange(...):
- Если Store Service возвращает не JSON (например HTML error page) →
RestTemplateброситRestClientException→ catch → return false → 422. - Но если Store Service возвращает JSON без поля
data→response.getBody().get("data")= null → return false → 422.
Подожди. Перечитаю внимательнее. Если validateStoreIds вернёт false, то бросится ApiException("INVALID_STORE_IDS", ...) с 422. Но ошибка в логах — INTERNAL_ERROR с 500. Значит проблема после этой проверки.
Пересмотр: ошибка при JPQL JOIN в findActiveWithPinByStoreId
Запрос:
SELECT es FROM EmployeeStore es
JOIN Employee e ON e.id = es.employeeId
WHERE es.storeId = :storeId
AND e.pinHash IS NOT NULL
AND e.status = 'active'Это JPQL с явным JOIN без @ManyToOne связи — EmployeeStore не имеет @ManyToOne на Employee. JPQL join JOIN Employee e ON e.id = es.employeeId может не работать в некоторых версиях Hibernate если нет маппинга связи. Hibernate может бросить QueryException → general Exception handler → 500.
Но этот запрос работал ранее (BR 1.4 уже реализован и использовался). Значит или он работает, или баг был скрыт (ранее не тестировали создание с PIN).
Результаты воспроизведения (2026-04-13)
Воспроизвёл на erp-test.nirbi.ru через API:
| Тест | Результат |
|---|---|
POST /employees role=franchisee, без store_ids | ✅ 201 Created — работает |
POST /employees role=cashier, store_ids=[реальный UUID] | ❌ 422 INVALID_STORE_IDS |
POST /employees role=manager, store_ids=[реальный UUID] | ❌ 422 INVALID_STORE_IDS |
POST /employees role=cashier, store_ids=[UUID из оригинального бага] | ❌ 422 INVALID_STORE_IDS |
Вывод: проблема воспроизводится, но текущая версия возвращает 422, а не 500. Оригинальный 500 мог быть от предыдущей версии кода или от другого сценария (например, BFF/Auth middleware exception).
Найденная корневая причина
StoreServiceClient.validateStoreIds() ВСЕГДА возвращает false — endpoint Store Service /internal/stores/validate либо:
- Не реализован (Store Service не имеет такого route → 404 → RestClientException → catch → return false)
- Возвращает формат, не совпадающий с ожидаемым (
{ data: { valid: true } })
Это приводит к тому, что создание любого сотрудника с store_ids невозможно (cashier, manager). Franchisee и franchise создаются нормально, т.к. у них store_ids не проверяются.
Дополнительная находка: pin_code vs pin
Фронтенд отправляет поле pin_code, бэкенд ожидает pin (в CreateEmployeeRequest.java нет @JsonProperty("pin_code")). PIN тихо теряется — кассир создаётся без PIN-кода.
Итого — два бага:
| # | Баг | Severity | Где |
|---|---|---|---|
| 1 | validateStoreIds всегда false → невозможно создать cashier/manager | P0 | Store Service: отсутствует endpoint /internal/stores/validate |
| 2 | pin_code → pin маппинг → PIN не сохраняется | P1 | User Service: CreateEmployeeRequest.pin нет @JsonProperty("pin_code") ИЛИ фронтенд шлёт неправильное имя поля |
Приоритет исправления
- Проверить Store Service: есть ли endpoint
/internal/stores/validate. Если нет — реализовать. Если есть — проверить формат ответа.- Исправить маппинг PIN: либо добавить
@JsonProperty("pin_code")в DTO, либо изменить фронтенд на отправкуpin.