PUT /api/agent/crm/clients/{clientId} — Редактировать клиента
Правка карточки клиентской базы агента: ФИО (только у незарегистрированного клиента), email и комментарий;
опциональная смена телефона с перепроверкой уникальности в агентстве (INV-CLI-1). У зарегистрированного
клиента (InvestorUserId != null) display-поля (ФИО, телефон) — дом identity, их синхронизирует консюмер
identity-user-upserted.md, поэтому этот метод их не перезаписывает.
Дом полей сущности — domain/client.md.
Назначение
Дать агенту, ведущему клиента (участнику его процессов), и руководителю агентства редактировать карточку клиента
на странице /crm/clients/[clientId]: заметку, контактный email, ФИО и телефон вручную заведённого (ещё не
зарегистрированного) клиента. Оптимистическая блокировка по RowVersion защищает от гонки параллельного
редактирования и фонового identity-синка.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | agent (JWT агента/руководителя агентства) |
| Feature group | clients |
| Статус | draft |
| Документ | docs/06-services/crm/api/agent/update-client.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Агент или Руководитель агентства) |
| Auth policy / Permissions | crm.clients.manage |
| Scope (RBAC) | организация (агентство TenantId = AgencyCompanyId) |
| Record-level | доступ по членству агентства: актор — активный участник процесса клиента или руководитель/администратор агентства того же TenantId (или Менеджер ПИН, platform-scope); иначе CRM_CLIENT_ACCESS_DENIED |
| Tenant isolation | TenantId в каждом запросе |
| Audit | update (UpdatedByUserId, UpdatedAt, Version++) |
Record-level: актор — активный участник процесса клиента или руководитель/администратор агентства того же
TenantId (или Менеджер ПИН, platform-scope); иначе CRM_CLIENT_ACCESS_DENIED.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | PUT |
| Route | /api/agent/crm/clients/{clientId} |
| Success status | 200 (карточка обновлена) |
| Idempotency header | не применяется (оптимистическая блокировка через RowVersion в теле) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
TenantId | identity claims | Guid | Да | Required | Tenant isolation |
ActorUserId | identity claims | Guid | Да | Required | record-level (участник процесса/руководитель) |
ClientId | route | Guid | Да | != Guid.Empty; существующая живая карточка | — |
FirstName | body | string | Да | 1..128, не пустое после trim; применяется только у незарегистрированного | PII |
LastName | body | string | Да | 1..128, не пустое после trim; только у незарегистрированного | PII |
MiddleName | body | string? | Нет | 0..128 | PII |
PhoneNumber | body | string | Да | нормализация и формат E.164 (^\+[1-9]\d{9,14}$), max 32; смена только у незарегистрированного | PII; маска +7***NNNN в логах |
Email | body | string? | Нет | формат email, 0..256 | PII |
Comment | body | string? | Нет | 0..4000 | — |
RowVersion | body | uint | Да | ожидаемая версия строки, полученная при чтении карточки | оптимистическая блокировка |
Модель ответа ClientCardModel
Проекция карточки клиента; дом полей — domain/client.md, ниже только проекция.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | ClientEntity.Id | — | Идентификатор карточки. |
FirstName / LastName / MiddleName | string / string / string? | Да/Да/Нет | ClientEntity.Name (FullName) | — | ФИО (актуально после правки/синка). |
PhoneNumber | string | Да | ClientEntity.PhoneNumber | актору с доступом — полностью | Телефон клиента. |
Email | string? | Нет | ClientEntity.Email | — | Контактный email. |
Comment | string? | Нет | ClientEntity.Comment | — | Заметка агента. |
Status | ClientStatus (enum строкой) | Да | ClientEntity.Status | — | Active / Archived. |
InvestorUserId | Guid? | Нет | ClientEntity.InvestorUserId | — | Пользователь-инвестор; null — не зарегистрирован (ФИО/телефон редактируемы). |
ActiveLeadId | Guid? | Нет | ClientEntity.ActiveLeadId | — | Текущий активный лид (INV-CLI-2). |
LastActivityAt | DateTimeOffset? | Нет | ClientEntity.LastActivityAt | — | Последняя активность. |
RowVersion | uint | Да | ClientEntity.RowVersion | — | Новая версия строки для следующего оптимистического апдейта. |
Алгоритм
- Контекст и доступ. Операцию выполняет агент/руководитель агентства (permission
crm.clients.manage) в рамкахTenantId. - Проверка входа и предусловий.
- формат:
ClientId != Guid.Empty;FirstName/LastNameнепустые ≤ 128;MiddleName≤ 128;PhoneNumber— E.164, ≤ 32;Email— формат email ≤ 256 (nullable);Comment≤ 4000 (nullable);RowVersionзадан — иначеVALIDATION_FAILED; - карточка существует в
TenantId— иначеCRM_CLIENT_NOT_FOUND; - состояние: карточка живая (
DeletedAt is null); редактирование архивной (Archived) допускается (правка заметки), soft-deleted — нет →CRM_CLIENT_NOT_FOUND; - record-level (членство агентства): актор — активный участник процесса клиента или руководитель/администратор агентства того же
TenantId(или Менеджер ПИН) — иначеCRM_CLIENT_ACCESS_DENIED.
- формат:
- Что читаем. Карточку клиента (единственный агрегат правки). Уникальность телефона проверяется при фактической смене (шаг 4).
- Бизнес-правила и переходы.
- Display-guard (registered): если клиент зарегистрирован (
InvestorUserId != null) и запрошенное ФИО ≠ текущему или нормализованный телефон ≠ текущему →CRM_CLIENT_DISPLAY_READONLY: ФИО/телефон зарегистрированного клиента — домidentity, меняются через identity и приходят консюмером identity-user-upserted.md. Email/Comment редактируемы всегда. - Смена телефона (незарегистрированный): если телефон меняется — перепроверить INV-CLI-1: если другая
живая карточка агентства уже занимает этот телефон →
CRM_CLIENT_PHONE_DUPLICATE. - Маппинг:
Name ← ФИО(только незарегистрированный);PhoneNumber ← нормализованный(только незарегистрированный);Email ← Email;Comment ← Comment;UpdatedByUserId = actor,UpdatedAt = now,Version++. Переходов статуса нет; доменные события не поднимаются (плоская правка — не бизнес-переход).
- Display-guard (registered): если клиент зарегистрирован (
- Что меняется. Карточка клиента обновляется; поисковая проекция клиента (
ClientSearchDocument: ФИО+телефон+email) обновляется проектором → поиск свежий. Оптимистическая блокировка поRowVersion: если строку изменили параллельно (агентом или фоновым identity-синком) —CRM_CONCURRENCY_CONFLICT(409, клиент перечитывает и повторяет). - Результат. Обновлённая карточка с новым
RowVersion→ HTTP200.
Диаграмма
Предусловия и постусловия
Предусловия. Актор имеет доступ к карточке по членству агентства (активный участник процесса клиента либо
руководитель/администратор агентства того же TenantId), с crm.clients.manage. Карточка существует, живая (DeletedAt is null); допустимы статусы
Active и Archived. Клиент располагает актуальным RowVersion, полученным при чтении карточки.
Контракт конкурентности (version-guard). Правка защищена оптимистической блокировкой по RowVersion.
Победитель — первый закоммитивший (агент или фоновый identity-синк по
identity-user-upserted.md); проигравший получает
CRM_CONCURRENCY_CONFLICT, перечитывает свежую карточку и повторяет. Для зарегистрированного клиента ФИО/
телефон — дом identity: метод их не перезаписывает (display-guard), они меняются только через identity и
приходят упомянутым консюмером.
Постусловие (успех). Для незарегистрированного клиента (InvestorUserId == null) обновлены Name и (при
фактической смене) PhoneNumber; для любого клиента — Email и Comment; UpdatedByUserId = actor, UpdatedAt = now, Version инкрементирован, выдан новый RowVersion. Поисковая проекция ClientSearchDocument (ФИО +
телефон + email) обновляется проектором (eventual, ADR-0059). Доменные и интеграционные события не
публикуются (плоская правка — не бизнес-переход).
Постусловие (отказ). Карточка не изменена; Version/RowVersion прежние; проекция не трогается.
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | none (клиент не публикует Kafka-событий, client.md) |
| Внешние вызовы | none (identity — источник display-полей — синхронизирует консюмер, не эта команда) |
| Проекции / поиск | поисковая проекция клиента (ClientSearchDocument) обновляется проектором (ADR-0059) |
| Уведомления | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | формат ФИО/телефона/email/comment или отсутствует RowVersion | «Проверьте корректность полей» | no retry |
CRM_CLIENT_NOT_FOUND | 404 | карточка не найдена в TenantId или soft-deleted | «Клиент не найден» | no retry |
CRM_CLIENT_ACCESS_DENIED | 403 | actor не участник процессов клиента и не руководитель агентства | «Нет доступа к карточке клиента» | no retry |
CRM_CLIENT_DISPLAY_READONLY | 422 | попытка сменить ФИО/телефон зарегистрированного клиента | «ФИО и телефон зарегистрированного клиента меняются в профиле» | no retry |
CRM_CLIENT_PHONE_DUPLICATE | 409 | INV-CLI-1: новый телефон занят другой живой карточкой агентства | «Клиент с таким телефоном уже есть» | no retry (сменить телефон) |
CRM_CONCURRENCY_CONFLICT | 409 | RowVersion не совпал (параллельная правка / identity-синк) | «Карточка изменена, обновите и повторите» | retry (перечитать → повторить) |
Совместимость и наблюдаемость
- Новые optional поля тела — additive (без breaking change); удаление/переименование/смена типа — breaking
(migration + rollout). Enum
ClientStatusв ответе — строкой; неизвестное значение клиент игнорирует (forward-compat). - Logs:
client-updated(ClientId, набор изменённых полей,Version; телефон/email — маской),client-update-conflict,client-update-denied; metrics:crm_client_update_total{result}, latency p95; traces — сквозной trace черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/.
Acceptance Criteria
- Happy path. Given незарегистрированный клиент (
InvestorUserId == null), корректные поля и актуальныйRowVersion; WhenPUT; Then обновлены ФИО/телефон/email/comment,Version++,200с новымRowVersion; поисковая проекция обновляется eventual. - Display-guard. Given зарегистрированный клиент (
InvestorUserId != null) и запрошенное ФИО либо телефон ≠ текущим; WhenPUT; Then422 CRM_CLIENT_DISPLAY_READONLY. - Email/Comment у зарегистрированного. Given зарегистрированный клиент и изменены только
Email/Comment; WhenPUT; Then200— email/комментарий редактируемы всегда. - Дубль телефона. Given незарегистрированный клиент, смена телефона на номер, занятый другой живой карточкой
агентства; When
PUT; Then409 CRM_CLIENT_PHONE_DUPLICATE(INV-CLI-1). - Конкурентность. Given строку изменили параллельно (другой агент или фоновый identity-синк); When
PUTс устаревшимRowVersion; Then409 CRM_CONCURRENCY_CONFLICT; клиент перечитывает и повторяет. - Доступ. Given актор не участник процессов клиента и не руководитель агентства того же
TenantId; WhenPUT; Then403 CRM_CLIENT_ACCESS_DENIED. - Не найдена. Given карточка не найдена в
TenantIdили soft-deleted; WhenPUT; Then404 CRM_CLIENT_NOT_FOUND. - Валидация. Given пустое ФИО, телефон не по E.164 или отсутствует
RowVersion; WhenPUT; Then400 VALIDATION_FAILED. - Архивная карточка. Given
Archived, правка только заметки; WhenPUT; Then200(правка разрешена).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность (дом): domain/client.md (поля
Name,PhoneNumber,Email,Comment,InvestorUserId,RowVersion; INV-CLI-1; VOFullName). - Синк display-полей зарегистрированного клиента: consumers/identity-user-upserted.md.
- README контекста crm; tenancy — ADR-0052.