GET /api/v1/admin/owner-mappings/conflicts — Очередь конфликтов связи Собственник ↔ Инвестор (listOwnerMappingConflicts)
Назначение
Admin-операция чтения очереди неразрешённых конфликтов сопоставления Собственник ↔ Инвестор для
Службы заботы: постраничный список связей OwnerMappingEntity со Status = Conflict, которые
автосвязка синхронизации Talent Owners установить не смогла (дубль телефона, невалидный номер, найденный
пользователь — не инвестор, коллизия id). Это входная точка ручного разбора: из каждой строки сотрудник
переходит в разрешение resolve-owner-mapping-conflict.md. Источник
конфликтов — consumer ../../consumers/talent-owners-sync.md
(доменное событие OwnerMappingConflictDetected). Дом полей связи —
../../domain/owner-mapping.md.
| Поле | Значение |
|---|
| Сервис/контекст | investor-owner |
| API area | admin (Служба заботы) |
| Feature group | owner-mappings |
| Статус | draft |
| Документ | docs/06-services/investor-owner/api/admin/list-owner-mapping-conflicts.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Operator(Admin) (сотрудник Службы заботы, Bearer JWT) |
| Auth policy / Permissions | authenticated; permission investor-owner.mappings.manage (../../../identity/domain/permission.md) |
| Scope (RBAC) | платформенный (не self, не tenant); record-level фильтр по InvestorUserId не применяется — платформенный просмотр очереди |
| Record-level | не применяется (платформенная роль); ключ выборки — Status = Conflict |
| Tenant isolation | не применяется — данные инвестора вне tenant (ADR-0052) |
| Audit | read-sensitive (deep audit, ADR-0053): платформенное чтение чужих PII (ФИО собственников) — запись доступа (actor sub, фильтры, traceparent) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/admin/owner-mappings/conflicts |
| Success status | 200 (в т.ч. пустая очередь — пустая страница, не ошибка) |
| Idempotency header | не применяется (safe GET) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
RequestedByUserId | identity claims (sub) | Guid | Да | валидный JWT — 401 до операции; permission — 403 | пишется в deep-audit (кто читал очередь) |
ConflictReason | query | OwnerLinkConflictReason? | Нет | enum ∈ {PhoneAlreadyMappedToAnotherOwner, PhoneInvalid, MatchedUserNotInvestor, IdentityIdCollision}; неизвестное значение → ValidationError | — |
Search | query | string? | Нет | 2..64 после trim; поиск по видимому хвосту телефона (PhoneNumber) и подстроке OwnerFullName (case-insensitive) | сам запрос — не PII; в логи пишется маской |
Page | query | int | Нет | >= 1, default 1 | — |
PageSize | query | int | Нет | 1..100, default 20 | — |
Модель ответа (постраничный список OwnerMappingConflictModel)
Постраничный контейнер (items, page, pageSize, totalCount); totalCount = число конфликтных
связей под фильтром. Финансовых данных в модели нет.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
items | IReadOnlyList<OwnerMappingConflictModel> | Да | проекция | — | Страница конфликтов (упорядочены CreatedAt asc — старейшие первыми, FIFO-очередь). |
page | int | Да | echo запроса | — | Текущая страница. |
pageSize | int | Да | echo запроса | — | Размер страницы. |
totalCount | long | Да | подсчёт по фильтру | — | Всего конфликтов под фильтром (0 при пустой очереди). |
OwnerMappingConflictModel (проекция агрегата — дом полей ../../domain/owner-mapping.md; здесь только дельта)
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
id | Guid | Да | OwnerMapping.Id (= OwnerId) | — | Идентификатор связи; ключ операции resolve-owner-mapping-conflict.md. |
phoneNumberMasked | string | Да | OwnerMapping.PhoneNumber | +7***1234 | Телефон собственника (PII — маскируется, в открытом виде не отдаётся). |
ownerFullName | OwnerFullNameModel | Да | VO FullName | — | ФИО собственника (снапшот Talent Owners) — нужно заботе для идентификации при разборе; доступ deep-audited. |
conflictReason | string (enum OwnerLinkConflictReason) | Да | OwnerMapping.ConflictReason | — | Машиночитаемая причина конфликта (INV-3 — при Conflict обязателен). |
conflictDetails | string? | Нет | OwnerMapping.ConflictDetails | телефон третьих лиц — уже маской | Человекочитаемая детализация (напр. id конкурирующей связи). |
createdAt | string (ISO 8601) | Да | OwnerMapping.CreatedAt | — | Когда связь появилась (первое событие синхронизации). |
lastSyncedAt | string (ISO 8601) | Да | OwnerMapping.LastSyncedAt | — | Время последнего применённого события Talent Owners. |
OwnerFullNameModel (проекция VO FullName — дельта: поле-в-поле)
| Поле | Тип | Обязательность | Источник | Описание |
|---|
firstName | string | Да | FullName.FirstName | Имя. |
lastName | string | Да (пустая строка допустима) | FullName.LastName | Фамилия. |
middleName | string? | Нет | FullName.MiddleName | Отчество. |
Алгоритм
- Контекст и доступ. Операцию выполняет оператор Службы заботы (
RequestedByUserId — из claim sub);
требуется permission investor-owner.mappings.manage (иначе 403); scope платформенный (не tenant).
Чтение чужих PII фиксируется в deep-audit (ADR-0053).
- Проверка входа.
Page >= 1, PageSize ∈ 1..100, ConflictReason ∈ enum (если задан), Search —
2..64 после trim (если задан) → иначе ValidationError (400). Пустая очередь — валидный ответ с
totalCount = 0.
- Что читаем. Постраничная выборка связей со
Status = Conflict (не удалённых); при заданном
ConflictReason — фильтр по причине; при заданном Search — поиск по подстроке телефона и по ФИО
(OwnerFullName); сортировка CreatedAt asc (FIFO — старейшие первыми). Тем же запросом возвращается
totalCount. Телефон из хранилища используется только для предиката поиска, наружу отдаётся маской.
- Маппинг и маскирование. Проекция — таблица
OwnerMappingConflictModel; phoneNumberMasked = Mask(PhoneNumber) (+7***1234); ownerFullName — как есть (сотрудник должен опознать собственника,
доступ покрыт deep-audit); createdAt/lastSyncedAt — ISO 8601.
- Результат.
200 (пусто → items = [], totalCount = 0). Консистентность: read-your-writes —
конфликт, только что зафиксированный синхронизацией, виден сразу.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Внешние вызовы | none |
| Проекции / поиск | none (btree ix_owner_mappings_status, ix_owner_mappings_created_at — ../../domain/owner-mapping.md; ADR-0056 — без Redis/поисковых индексов) |
| Аудит | запись доступа (deep audit) при чтении чужих PII |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен/невалиден JWT | без тела схемы (фронт очищает сессию) | re-login |
FORBIDDEN | 403 | Нет investor-owner.mappings.manage | «Недостаточно прав» | no retry |
ValidationError | 400 | Некорректные Page/PageSize/ConflictReason/Search | Список ошибок полей | no retry |
| — (пустая очередь, не ошибка) | 200 | Конфликтов под фильтром нет | items = [], totalCount = 0 | — |
Совместимость и наблюдаемость
OwnerMappingConflictModel — проекция агрегата; новые optional поля additive; удаление/переименование/смена
типа — breaking (migration + rollout admin-UI). Enum OwnerLinkConflictReason сериализуется строкой;
клиент обязан игнорировать неизвестные значения (unknown enum handling — показать строку без бейджа).
- Logs:
owner-mapping-conflicts-listed (RequestedByUserId, ConflictReason, размер страницы,
totalCount; телефон/ФИО/Search — маской, суммы отсутствуют); metrics:
investor_owner_conflicts_listed_total{result}, latency histogram, gauge investor_owner_conflicts_open
(алерт роста > 10/день — ../../README.md); traces — сквозной trace; dashboard
investor-owner-overview; runbook — «разбор конфликтов связи» (../../README.md).
Acceptance Criteria
- Given оператор заботы и очередь с конфликтными связями, When
GET /admin/owner-mappings/conflicts
без фильтров, Then 200 со страницей Status = Conflict в порядке CreatedAt asc (FIFO), totalCount
= число конфликтов под фильтром; телефон отдан маской +7***1234, ФИО — как есть; чтение зафиксировано в
deep-audit.
- Given пустая очередь (нет конфликтов под фильтром), When запрос, Then
200 с items = [],
totalCount = 0.
- Given
ConflictReason = PhoneAlreadyMappedToAnotherOwner, When запрос, Then возвращаются только
связи с этой причиной; Given Search по хвосту телефона/подстроке ФИО, Then предикат применяется к
PhoneNumber/OwnerFullName, но телефон в ответе остаётся маской.
- Given конфликт, только что зафиксированный синхронизацией, When оператор открывает очередь, Then
он виден сразу (read-your-writes).
- Given
Page < 1, PageSize ∉ 1..100, неизвестный ConflictReason или Search вне 2..64, When
запрос, Then ValidationError (400).
- Given нет
investor-owner.mappings.manage, When запрос, Then 403; невалидный JWT — 401.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы