User Service — BR 1.4.4

Репо: erp-user-service Контракт: API + Data Model

Фаза 1 — Liquibase changesets (4 файла)

  • 018-br-1-4-4-create-franchises.xml:
    • CREATE TABLE franchises (id UUID PK, name VARCHAR(255), type VARCHAR(20) NOT NULL DEFAULT 'corporate', created_at, updated_at)
    • CHECK (type IN ('corporate','individual'))
    • Seed: INSERT INTO franchises (id, name, type) VALUES ('00000000-0000-0000-0000-000000000001', 'Тестовая франшиза', 'corporate')NOT EXISTS guard)
  • 019-br-1-4-4-franchises-fk.xml:
    • ALTER TABLE employees ADD CONSTRAINT fk_employees_franchise FOREIGN KEY (franchise_id) REFERENCES franchises(id)
    • То же для legal_entities.franchise_id, roles.franchise_id, employee_stores (если есть поле) и т.д.
  • 020-br-1-4-4-role-owner-le.xml:
    • ALTER TABLE roles ADD COLUMN owner_legal_entity_id UUID NULL
    • FK → legal_entities(id) ON DELETE CASCADE (скрытая роль удаляется вместе с ЮЛ партнёра)
    • CREATE INDEX idx_roles_hidden ON roles(owner_legal_entity_id) WHERE owner_legal_entity_id IS NOT NULL
    • CREATE INDEX idx_roles_visible ON roles(franchise_id) WHERE owner_legal_entity_id IS NULL AND deleted_at IS NULL
    • Удалить старый uq_roles_name_per_franchise и пересоздать с исключением скрытых: UNIQUE (franchise_id, LOWER(name)) WHERE deleted_at IS NULL AND owner_legal_entity_id IS NULL
  • 021-br-1-4-4-drop-employees-role.xml:
    • ALTER TABLE employees DROP COLUMN role
    • DROP INDEX IF EXISTS idx_employees_role
    • CHECK constraint на role — исчезнет автоматически

Фаза 2 — Entity / Repository / DTO

  • Franchise entity (+ repository FranchiseRepository с findById)
  • Role entity — добавить ownerLegalEntityId поле
  • Employee entity — убрать поле role
  • RoleRepository — методы:
    • findAllByFranchiseIdAndOwnerLegalEntityIdIsNull (для обычных ролей)
    • findByOwnerLegalEntityId (для скрытых ролей при управлении через ЮЛ)
  • DTO:
    • CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeResponse, InternalEmployeeResponse — убрать role, store_ids (отдельное поле)
    • CreateLegalEntityRequest — добавить ownerPermissions: { mode, permissions }
    • OwnerPermissionsResponse (mode + permissions)
    • ScopeResponse (type + ids)
    • FranchiseResponse (id, name, type)

Фаза 3 — ScopeService + переписывание авторизации

  • ScopeService.computeScope(userId):
    • Lookup legal_entities WHERE owner_user_id = userId (один user может владеть не одним ЮЛ в будущем, но пока один)
    • Если найдено type=franchise → { type: all_franchise }
    • Если найдено type=franchisee → { type: legal_entity_ids, legal_entity_ids: [le.id, ...] }
    • Иначе: SELECT DISTINCT store_id FROM employee_role_stores WHERE employee_id = userId → { type: store_ids, store_ids: [...] }
  • В LegalEntityService — заменить switch(user.getRole()) на проверки через ScopeService:
    • Владелец франшизы: scope.type == all_franchise → доступ ко всем ЮЛ
    • Владелец партнёра: legal_entity_id in scope.legal_entity_ids → только свои
  • В EmployeeService — аналогично: фильтрация employees по scope + permission-check для edit/delete
  • В RoleServiceGET /roles фильтрует owner_legal_entity_id IS NULL по умолчанию

Фаза 4 — Новые controllers и endpoints

  • FranchiseController:
    • GET /api/v1/franchises/{id} — вернуть FranchiseResponse. Проверка: id == user.franchise_id, иначе 404
  • LegalEntityOwnerPermissionsController:
    • GET /api/v1/legal-entities/{id}/owner-permissions — вернуть { mode, permissions }
      • Если владелец привязан к системной «Администратор» → mode=full
      • Если к скрытой роли → mode=custom, permissions=[...]
    • PUT /api/v1/legal-entities/{id}/owner-permissions — body { mode, permissions? }
      • При смене full→custom: создать скрытую роль, отвязать «Администратор», привязать новую
      • При смене custom→full: удалить скрытую роль, привязать «Администратор»
      • При custom→custom: обновить permissions скрытой роли
      • Форсить минимум: pos.access, stores.read, employees.read
  • InternalUserController:
    • GET /internal/users/{id}/scope — вернуть ScopeResponse через ScopeService
    • Response /internal/users/{id}/permissions — опционально расширить полем scope

Фаза 5 — Обновление existing endpoints

  • POST /employees — убрать валидацию role (поле больше не существует в request), убрать ошибки ADMIN_ROLE_FORBIDDEN
  • PATCH /employees/{id} — убрать ROLE_IMMUTABLE
  • GET /employees, GET /employees/{id} — response без поля role
  • POST /legal-entities (type=franchisee):
    • Принимать поле owner_permissions в body
    • В транзакции: создать LE + Employee-owner + назначить роль (системная «Администратор» при mode=full, скрытая при mode=custom)
    • Ошибка FRANCHISE_TYPE_INDIVIDUAL если franchise.type == individual
  • /internal/users/validate-credentials|by-email|validate-pin — убрать role из response, добавить scope (через ScopeService + cache)
  • /internal/users/validate-pin — заменить enum-проверку на: PIN валиден + в агрегате permissions есть pos.access. Иначе POS_ACCESS_DENIED

Фаза 6 — Тесты (optional, если время)

  • ScopeService unit тесты (3 сценария)
  • OwnerPermissions integration (full → custom → full)
  • FranchiseController (404 при чужой franchise_id)

Выходные критерии

  • Миграции применяются на чистой БД и на БД с данными BR 1.4.3
  • Все switch(role) в коде убраны (grep должен быть пуст)
  • Docker build без ошибок
  • Коммит + push в origin main