BUG-016: 500 при создании кассира

Описание

При попытке создать сотрудника с ролью cashier через админку возвращается 500 Internal Server Error. Создание сотрудников с другими ролями не проверялось — возможно, проблема затрагивает и другие роли.

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

  1. Открыть админку → Сотрудники → Создать
  2. Заполнить форму:
    • Имя: Иван
    • Фамилия: Куликов
    • Email: markin1999981@gmail.com
    • Пароль: 12344321sS
    • Телефон: +79000870817
    • PIN: 1234
    • Роль: cashier
    • Торговая точка: 9051b882-371b-412d-a88a-c2a5d357152c
  3. Нажать “Сохранить”

Запрос

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:

  1. validateCreateAccess(user) — проверка роли вызывающего (franchise/franchisee)
  2. validateRoleEscalation(user, "cashier") — franchise(4) > cashier(1) ✓
  3. existsByFranchiseIdAndEmail() — проверка дубликата email
  4. Валидация store_ids: storeIds.size() != 1 — кассиру нужно ровно 1 ТТ
  5. storeServiceClient.validateStoreIds(storeIds, franchiseId) — вызов Store Service
  6. validatePinUniquenessPerStore(pin, storeIds, null) — проверка PIN
  7. employeeRepository.save(employee) — сохранение
  8. saveStoreAssignments()employeeStoreRepository.saveAll()
  9. 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 != null check. Ок, 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 без поля dataresponse.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 либо:

  1. Не реализован (Store Service не имеет такого route → 404 → RestClientException → catch → return false)
  2. Возвращает формат, не совпадающий с ожидаемым ({ data: { valid: true } })

Это приводит к тому, что создание любого сотрудника с store_ids невозможно (cashier, manager). Franchisee и franchise создаются нормально, т.к. у них store_ids не проверяются.

Дополнительная находка: pin_code vs pin

Фронтенд отправляет поле pin_code, бэкенд ожидает pinCreateEmployeeRequest.java нет @JsonProperty("pin_code")). PIN тихо теряется — кассир создаётся без PIN-кода.

Итого — два бага:

#БагSeverityГде
1validateStoreIds всегда false → невозможно создать cashier/managerP0Store Service: отсутствует endpoint /internal/stores/validate
2pin_code → pin маппинг → PIN не сохраняетсяP1User Service: CreateEmployeeRequest.pin нет @JsonProperty("pin_code") ИЛИ фронтенд шлёт неправильное имя поля

Приоритет исправления

  1. Проверить Store Service: есть ли endpoint /internal/stores/validate. Если нет — реализовать. Если есть — проверить формат ответа.
  2. Исправить маппинг PIN: либо добавить @JsonProperty("pin_code") в DTO, либо изменить фронтенд на отправку pin.

Связи