Paykeeper Adapter — BR 3.5
Контракты
- Data Model — 2 новые таблицы (
paykeeper_users,paykeeper_user_imports)- API — 4 admin-эндпоинта для импорта сотрудников
- Бизнес-логика: Импорт сотрудников из PayKeeper
Что делаем
Миграция 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 в чеке
- UNIQUE
- Таблица
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)
- INDEX
- Регистрация в
db.changelog-master.xml.
- Таблица
Entities + Repositories
-
com.erp.paykeeper.entity.PaykeeperUser+PaykeeperUserRepository:findByAccountIdAndPkUserId(...)— для дедупа в previewfindByAccountIdAndEmployeeId(...)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.
- Auth: Basic auth с расшифрованным
User Service client расширение
- В существующем
com.erp.paykeeper.client.UserServiceClientдобавить методы:Optional<EmployeeDto> findByEmail(UUID franchiseId, String email)→GET /internal/users/by-email?franchise_id=X&email=YEmployeeDto createEmployee(UUID franchiseId, CreateEmployeeRequest req)→POST /api/v1/employeesсX-Service-TokenEmployeeDto 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):- Загрузить аккаунт, проверить
status='active'(иначеAccountNotActiveException) - Резолв
franchise_idчерезUserServiceClient.getFranchiseIdByLegalEntity(с Redis-кэшем 5 мин) - Проверить scope: caller имеет ли доступ к этому аккаунту (через
legal_entity_id+ scope правила) usersClient.fetchUsers(account)→pkUsers- Загрузить все
paykeeper_usersдля этого account (один query) - Для каждого
pkUser:- Если есть mapping →
match_status="already_linked", подтянутьlinked_employee(опц. через User ServiceGET /api/v1/employees/{id}для read-only отображения) - Иначе если
pkUser.emailне пустой —userServiceClient.findByEmail(franchiseId, email)→ если найден →match_status="matched_email",matched_employeeв response - Иначе →
match_status="new"
- Если есть mapping →
- Возвращаем массив со всеми pk-полями + match info.
- Загрузить аккаунт, проверить
executeImport(UUID accountId, ImportRequest req, JwtUser caller):- Создать
paykeeper_user_importsrow (status=running, initiated_by_user_id=caller.userId) - 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 → INSERTpaykeeper_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)→ INSERTpaykeeper_users→ users_updated++
- Сформировать PatchEmployeeRequest с не-пустыми полями из
skip:- errors_json +=
{pk_user_id, pk_login, action: "skipped", message: "skipped by owner"}→ users_skipped++
- errors_json +=
- 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
- Возвращаем
ImportRunDtoс финальными счётчиками.
- Создать
listImports(UUID accountId, int limit, Optional<Instant> since)— выборка из repogetImportDetails(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.editPOST /internal/paykeeper/accounts/{id}/employees/import— Bearer JWT, требуетintegrations.manage+employees.editGET /internal/paykeeper/accounts/{id}/employees/imports— Bearer JWT, требуетintegrations.readGET /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_FOUNDAccountNotActiveException→ 422 ACCOUNT_NOT_ACTIVEPkConnectionException→ 422 PK_CONNECTION_FAILED + msgValidationException→ 400 VALIDATION_ERRORForbiddenException→ 403 FORBIDDEN
Конфигурация
- Добавить env vars в
application.yml:PK_USERS_TIMEOUT_MS=15000(timeout запроса к PK users endpoint)
- Обновить
Overview.mdPaykeeper 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:
- На
erp-test.nirbi.ru(demo-coffee tenant) → Сотрудники → «Выгрузить из PK» → должен появиться список 4 пользователей из koala-test - Через wizard — обработать все 4 строки разными действиями (create_new / link_existing / skip)
- Проверить таблицы:
paykeeper_users— должно быть N записей mappingpaykeeper_user_imports— должна быть запись прогона со счётчиками
- Повторный preview → 4 строки с
match_status="already_linked"для импортированных - Журнал импортов через 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)
Ссылки
- BR 3.5
- Overview
- API
- Data Model
- User Service · API (существующие endpoints)
- BR 3.4 — Adapter (эталон структуры)