Paykeeper Adapter — BR 3.5

Контракты

Что делаем

Миграция Liquibase

  • src/main/resources/db/changelog/006_user_imports.xml:
    • Таблица paykeeper_users: id, account_id (FK ON DELETE CASCADE), pk_user_id varchar(50), pk_login varchar(100), employee_id uuid, created_at, updated_at.
      • UNIQUE (account_id, pk_user_id)
      • UNIQUE (account_id, employee_id)
      • INDEX (employee_id) — для lookup при чеке от PK
      • INDEX (account_id, pk_login) — для матча по login если PK API не вернул user_id в чеке
    • Таблица paykeeper_user_imports: id, account_id, trigger varchar(20) default 'manual', initiated_by_user_id uuid, started_at, finished_at, счётчики (users_total, users_created, users_linked, users_updated, users_skipped, users_errored), status varchar(20) default 'running', last_error text, errors_json jsonb.
      • INDEX (account_id, started_at DESC)
      • INDEX (account_id, status)
    • Регистрация в db.changelog-master.xml.

Entities + Repositories

  • com.erp.paykeeper.entity.PaykeeperUser + PaykeeperUserRepository:
    • findByAccountIdAndPkUserId(...) — для дедупа в preview
    • findByAccountIdAndEmployeeId(...)
    • findAllByAccountId(...) — для preview matching (загрузить все mapping’и одного аккаунта)
    • findAllByEmployeeId(...) — для отчётов «выручка по кассиру» (на будущее)
  • com.erp.paykeeper.entity.PaykeeperUserImport + PaykeeperUserImportRepository:
    • findTopByAccountIdOrderByStartedAtDesc(...)
    • findAllByAccountIdOrderByStartedAtDesc(Pageable) — для журнала
    • findByIdAndAccountId(...) — для деталей прогона

PayKeeper users client

  • com.erp.paykeeper.pk.PayKeeperUsersClient — HTTP-клиент к {pk_server_host}/info/organization/users/.
    • Auth: Basic auth с расшифрованным pk_login/pk_password из paykeeper_accounts (используем существующий SecretsService для AES-GCM расшифровки).
    • Метод: List<Map<String,Object>> fetchUsers(PaykeeperAccount account)GET /info/organization/users/ → парсинг массива объектов.
    • Парсим поля: id, login, email (может быть null/empty), fio (может быть null/empty), admin (string “true”/“false” → boolean), refund (string число → ignored), invoices_only (boolean → ignored).
    • Retry: 1 попытка (no retry для GET — если упало → возвращаем 422 PK_CONNECTION_FAILED).
    • Timeout: 15 сек.
    • При ошибке (4xx/5xx/timeout) — выбрасываем PkConnectionException с msg от PK.

User Service client расширение

  • В существующем com.erp.paykeeper.client.UserServiceClient добавить методы:
    • Optional<EmployeeDto> findByEmail(UUID franchiseId, String email)GET /internal/users/by-email?franchise_id=X&email=Y
    • EmployeeDto createEmployee(UUID franchiseId, CreateEmployeeRequest req)POST /api/v1/employees с X-Service-Token
    • EmployeeDto updateEmployee(UUID employeeId, PatchEmployeeRequest req)PATCH /api/v1/employees/{id} с X-Service-Token
    • DTO: EmployeeDto, CreateEmployeeRequest, PatchEmployeeRequest — соответствуют контрактам User Service.

Бизнес-логика

  • com.erp.paykeeper.service.UserImportService:
    • previewImport(UUID accountId, JwtUser caller):
      1. Загрузить аккаунт, проверить status='active' (иначе AccountNotActiveException)
      2. Резолв franchise_id через UserServiceClient.getFranchiseIdByLegalEntity (с Redis-кэшем 5 мин)
      3. Проверить scope: caller имеет ли доступ к этому аккаунту (через legal_entity_id + scope правила)
      4. usersClient.fetchUsers(account)pkUsers
      5. Загрузить все paykeeper_users для этого account (один query)
      6. Для каждого pkUser:
        • Если есть mapping → match_status="already_linked", подтянуть linked_employee (опц. через User Service GET /api/v1/employees/{id} для read-only отображения)
        • Иначе если pkUser.email не пустой — userServiceClient.findByEmail(franchiseId, email) → если найден → match_status="matched_email", matched_employee в response
        • Иначе → match_status="new"
      7. Возвращаем массив со всеми pk-полями + match info.
    • executeImport(UUID accountId, ImportRequest req, JwtUser caller):
      1. Создать paykeeper_user_imports row (status=running, initiated_by_user_id=caller.userId)
      2. Per decision (transactional границы — каждый decision в своей транзакции, чтобы один failure не откатывал всё):
        • create_new / create_with_alt_email:
          • Если generate_password=true → backend генерирует случайный пароль (16 чарков), флаг send_reset_link=true в CreateEmployeeRequest (User Service знает что надо отправить email)
          • userServiceClient.createEmployee(franchiseId, req) → если ok → INSERT paykeeper_users → users_created++
          • Если ошибка (409 EMAIL_TAKEN, 400 VALIDATION) → запись в errors_json + users_errored++
        • link_existing:
          • Проверить что matched_employee_id принадлежит franchise_id (защита от подмены)
          • INSERT paykeeper_users(employee_id=matched_employee_id) → users_linked++
        • update_existing:
          • Сформировать PatchEmployeeRequest с не-пустыми полями из employee_data
          • userServiceClient.updateEmployee(matched_employee_id, patch) → INSERT paykeeper_users → users_updated++
        • skip:
          • errors_json += {pk_user_id, pk_login, action: "skipped", message: "skipped by owner"} → users_skipped++
      3. UPDATE paykeeper_user_imports финальными счётчиками + status:
        • success если users_errored == 0 и users_total > users_skipped (хоть что-то обработано)
        • partial если users_errored > 0 и есть успешные (created+linked+updated > 0)
        • failed если все processed = errored или success = 0
      4. Возвращаем ImportRunDto с финальными счётчиками.
    • listImports(UUID accountId, int limit, Optional<Instant> since) — выборка из repo
    • getImportDetails(UUID accountId, UUID runId) — возвращает + errors_json

REST controller

  • com.erp.paykeeper.controller.UserImportController:
    • POST /internal/paykeeper/accounts/{id}/employees/preview — Bearer JWT, требует integrations.manage + employees.edit
    • POST /internal/paykeeper/accounts/{id}/employees/import — Bearer JWT, требует integrations.manage + employees.edit
    • GET /internal/paykeeper/accounts/{id}/employees/imports — Bearer JWT, требует integrations.read
    • GET /internal/paykeeper/accounts/{id}/employees/imports/{run_id} — Bearer JWT, требует integrations.read
  • DTO: PreviewItemDto, ImportDecisionDto, EmployeeDataDto, ImportRunDto.
  • Permission-check: расширить существующий @RequireAnyPermission чтобы поддерживал AND-комбо. Либо использовать сразу две аннотации (если фреймворк поддерживает).
  • Mapping исключений → HTTP коды:
    • AccountNotFoundException → 404 ACCOUNT_NOT_FOUND
    • AccountNotActiveException → 422 ACCOUNT_NOT_ACTIVE
    • PkConnectionException → 422 PK_CONNECTION_FAILED + msg
    • ValidationException → 400 VALIDATION_ERROR
    • ForbiddenException → 403 FORBIDDEN

Конфигурация

  • Добавить env vars в application.yml:
    • PK_USERS_TIMEOUT_MS=15000 (timeout запроса к PK users endpoint)
  • Обновить Overview.md Paykeeper Adapter (уже сделано в Шаге 2).

Тесты

  • Unit-тесты UserImportService:
    • preview с разными статусами (new / matched_email / already_linked)
    • import с разными actions (create_new / link_existing / update_existing / skip / create_with_alt_email)
    • errors_json при ошибках User Service
  • WireMock-тесты для PayKeeperUsersClient:
    • Успешный response с реальным payload (id/login/email/fio/admin)
    • Empty array
    • 401 / 500 / timeout от PK
  • Integration-тест UserImportController через @SpringBootTest — happy-path preview + import.

Verification

После деплоя на VPS:

  1. На erp-test.nirbi.ru (demo-coffee tenant) → Сотрудники → «Выгрузить из PK» → должен появиться список 4 пользователей из koala-test
  2. Через wizard — обработать все 4 строки разными действиями (create_new / link_existing / skip)
  3. Проверить таблицы:
    • paykeeper_users — должно быть N записей mapping
    • paykeeper_user_imports — должна быть запись прогона со счётчиками
  4. Повторный preview → 4 строки с match_status="already_linked" для импортированных
  5. Журнал импортов через GET endpoint — возвращает прогон с детализацией

Не делаем

  • ❌ Outbox для импорта — синхронно с timeout 60s (см. §3 контракта import в API.md)
  • ❌ Reconciliation cron / periodic pull — только manual в P0
  • ❌ Push в PK при создании employee у нас — отдельная BR 3.6
  • ❌ Webhook на изменение employee в нашем User Service → удалить mapping (отдельная BR на cleanup)

Ссылки