Перейти к основному содержимому

POST /api/v1/profile/requisite-templates — Создание шаблона реквизитов (createRequisiteTemplate)

Назначение

Создать именованный переиспользуемый набор реквизитов для оформления брони и сделки CRM: «контакты, реквизиты, документы как шаблон переиспользовать в бронировании, в оформлении сделок». Шаблон связывает персону (person.md) с конкретными её документами (person-document.md) — удостоверение личности, опц. ИНН, доп. документы — и опциональными контактами-оверрайдами для документов сделки. Состав (Composition) хранится открыто (только id — не PII), контакты-оверрайды хранятся зашифрованными. Операцию вызывает инвестор из ЛК. Позже crm синхронно получает иммутабельный расшифрованный снапшот через internal API (get-requisite-snapshot.md); события шаблонов в шину не публикуются (ADR-0056, PII не выносится). Сценарий — UC-PRF-004.

Metadata

ПолеЗначение
Сервис/контекстprofile
API areapublic
Feature grouprequisites
Статусdraft
Документdocs/06-services/profile/api/public/create-requisite-template.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Bearer JWT), UserType = Investor (INV-RQT-6)
Auth policy / Permissionsauthenticated; permission profile.requisites.manage-self
Scope (RBAC)self
Record-levelOwnerUserId = UserId из sub (из body не принимается); чужие personId/документы → PROFILE_TEMPLATE_FOREIGN_REFERENCE без раскрытия существования
Tenant isolationне применяется (ADR-0052: инвестор вне tenant)
Auditcreate: CreatedByUserId = UserId, Version = 1, событие RequisiteTemplateCreatedEvent (+ RequisiteTemplateChangedEvent при SetDefault)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/profile/requisite-templates
Success status201 + Location: /api/v1/profile/requisite-templates/{id}
Idempotency headerIdempotency-Key (обязателен; двойной сабмит формы дополнительно гасится уникальностью имени INV-RQT-4)
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса (body CreateRequisiteTemplateForm)

Валидация формата/лимитов выполняется до шифрования, на исходных значениях. Модели полей документов и типы — дом person-document.md; состав — RequisiteTemplateComposition (requisite-template.md), здесь не переописываются.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity claims (sub)GuidДавалидный JWT401 без токена
namebodystringДа1–128 символов, не пустое после trim; уникально среди активных шаблонов владельца, case-insensitive (INV-RQT-4)не PII (имя в UI)
personIdbodyGuidДаNotEmpty; активная персона владельца (INV-RQT-1/3)чужая → PROFILE_TEMPLATE_FOREIGN_REFERENCE
composition.identityDocumentIdbodyGuidДаNotEmpty; активный документ персоны паспортного типа {RfPassport, RbPassport, KzPassport, CisPassport, ForeignPassport} (INV-RQT-2)
composition.innDocumentIdbodyGuid?Нетесли задан — активный документ персоны Type = Inn (INV-RQT-2)
composition.additionalDocumentIdsbodyList<Guid>Да (может быть пустым)≤ 10 элементов; без дублей; без повтора identityDocumentId/innDocumentId; каждый — активный документ этой персоны
contactEmailbodystring?Нетформат email, ≤ 256 символов (валидация до шифрования)PII → ContactEmailCipher, не логируется, наружу не отдаётся
contactPhonebodystring?НетE.164 (^\+[1-9]\d{9,14}$), ≤ 32 символовPII → ContactPhoneCipher, не отдаётся
isDefaultbodybool?Нетdefault false; true — шаблон по умолчанию (SetDefault-логика, INV-RQT-5)

Модель ответа RequisiteTemplateModel

Дом проекции — list-requisite-templates.md; значения контактов наружу не отдаются — только признак наличия. Проекция агрегата requisite-template.md.

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаRequisiteTemplateEntity.IdИдентификатор шаблона.
namestringДаNameИмя шаблона в UI.
personIdGuidДаPersonIdПерсона, на которую оформляются документы сделки.
statusRequisiteTemplateStatusДаStatusEnum строкой; при создании Active.
isDefaultboolДаIsDefaultШаблон по умолчанию для формы бронирования.
compositionRequisiteTemplateCompositionДаCompositionContainer-модель: identityDocumentId, innDocumentId?, additionalDocumentIds — только id (не PII, отдаётся открыто).
hasContactEmailboolДаcomputed (ContactEmailCipher != null)значение исключеноПризнак заданного контактного email-оверрайда.
hasContactPhoneboolДаcomputed (ContactPhoneCipher != null)значение исключеноПризнак заданного контактного телефона-оверрайда.
lastUsedAtDateTimeOffset?НетLastUsedAtПри создании null.
usageCountintДаUsageCountПри создании 0.
createdAtDateTimeOffsetДаCreatedAtМомент создания.
updatedAtDateTimeOffsetДаUpdatedAtМомент последнего изменения.
versionlongДаVersionПри создании 1.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (актор User, UserId из токена); tenant не применяется (инвестор вне tenant, ADR-0052); требуется право profile.requisites.manage-self. Повторный запрос с тем же Idempotency-Key возвращает ранее сохранённый результат без повторного создания.
  2. Проверка входа. name — 1–128 символов, не пустое после trim; personId/identityDocumentId заданы; additionalDocumentIds — ≤ 10, без дублей и без пересечения с identityDocumentId/ innDocumentId; формат contactEmail/contactPhone (E.164) — на исходных значениях до шифрования. Нарушение → ValidationError.
  3. Проверка предусловий.
    • Профиль владельца существует; иначе PROFILE_NOT_FOUND. Профиль активен (Status = Active, иначе PROFILE_USER_BLOCKED, INV-PRF-3) и в режиме самоуправления (ContactsManagedBy = SelfService, иначе PROFILE_MANAGED_EXTERNALLY, INV-PRF-2 — менеджеры застройщика управляются внешней системой); тип пользователя — Investor (иначе PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE, INV-RQT-6/INV-PRF-4).
    • Лимит: активных шаблонов владельца < 20; иначе PROFILE_TEMPLATES_LIMIT_EXCEEDED (INV-RQT-7).
    • Уникальность имени: среди активных шаблонов владельца нет шаблона с тем же именем (case-insensitive); иначе PROFILE_TEMPLATE_NAME_TAKEN (INV-RQT-4).
    • Персона personId принадлежит владельцу; чужая → PROFILE_TEMPLATE_FOREIGN_REFERENCE (INV-RQT-1, без подтверждения существования чужой персоны); персона активна, иначе PROFILE_TEMPLATE_REFERENCE_ARCHIVED (INV-RQT-3).
  4. Бизнес-правила и маппинг. Сверить состав по документам персоны: каждый *DocumentId (identity + inn? + additional) принадлежит персоне — иначе PROFILE_TEMPLATE_FOREIGN_REFERENCE (INV-RQT-1); identityDocumentId — паспортного типа, innDocumentId — типа Inn — иначе PROFILE_TEMPLATE_INVALID_DOCUMENT_TYPE (INV-RQT-2); все документы состава активны — иначе PROFILE_TEMPLATE_REFERENCE_ARCHIVED (INV-RQT-3). Контакты-оверрайды шифруются единым KeyVersion записи; в состав пишутся только id (не PII). Создаётся шаблон: Status = Active, IsDefault = false, UsageCount = 0, Version = 1; фиксируется факт RequisiteTemplateCreatedEvent. При isDefault = true применяется логика SetDefault: снять флаг с прежнего default-шаблона, пометить новый как default, синхронизировать Profile.DefaultRequisiteTemplateId; фиксируется RequisiteTemplateChangedEvent (INV-RQT-5 + INV-PRF-5).
  5. Согласованность. В одном согласованном изменении сохраняются шаблон и (при isDefault = true) прежний default и профиль. Гонку имени страхует уникальность имени владельца (конфликт → PROFILE_TEMPLATE_NAME_TAKEN); гонку default — уникальность default владельца (конфликт → PROFILE_TEMPLATE_DEFAULT_CONFLICT, retry).
  6. Результат. Модель шаблона RequisiteTemplateModel; контакты наружу не отдаются — только hasContactEmail/hasContactPhone; composition — только id. HTTP 201 + Location: /api/v1/profile/requisite-templates/{id}.

Диаграмма

Побочные эффекты

ТипДетали
Внешние вызовыпровайдер ключей шифрования (хранилище секретов) для шифрования контактов-оверрайдов; недоступность — fail-closed PROFILE_CRYPTO_UNAVAILABLE
ПроекцииProfileEntity.DefaultRequisiteTemplateId при isDefault = true (та же транзакция)

Интеграционные события в шину не публикуются (ADR-0056, PII не выносится); CRM получает снапшот синхронно через internal API.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен JWTбез телаre-login
ValidationError400Формат/длины/лимиты списков/email/E.164перечень невалидных полейno retry
PROFILE_NOT_FOUND404Профиль не создан консюмером identity«Профиль ещё создаётся»retry с backoff
PROFILE_USER_BLOCKED400Status != Active (INV-PRF-3)«Учётная запись заблокирована»no retry
PROFILE_MANAGED_EXTERNALLY403ContactsManagedBy = TalentSync (INV-PRF-2)«Данные управляются внешней системой»no retry
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE403UserType != Investor (INV-RQT-6)«Доступно только инвесторам»no retry
PROFILE_TEMPLATE_FOREIGN_REFERENCE400Персона/документ не принадлежат владельцу (INV-RQT-1)«Проверьте выбор персоны и документов»no retry
PROFILE_TEMPLATE_INVALID_DOCUMENT_TYPE400Identity-документ не паспортного типа / innDocumentId не Inn (INV-RQT-2)«Некорректный тип документа»no retry
PROFILE_TEMPLATE_REFERENCE_ARCHIVED409Архивная персона/документ в составе (INV-RQT-3)«Обновите состав: элемент в архиве»no retry (обновить состав)
PROFILE_TEMPLATE_NAME_TAKEN409Дубль имени среди активных (INV-RQT-4)«Шаблон с таким именем уже есть»no retry
PROFILE_TEMPLATES_LIMIT_EXCEEDED409≥ 20 активных шаблонов (INV-RQT-7)«Достигнут лимит шаблонов (20)»no retry
PROFILE_TEMPLATE_DEFAULT_CONFLICT409Гонка SetDefault (INV-RQT-5, конфликт уникальности default)«Повторите попытку»retry
PROFILE_CRYPTO_UNAVAILABLE503Ключи шифрования недоступны«Сервис временно недоступен»retry

Совместимость и наблюдаемость

  • Форма расширяется только optional-полями (additive); composition — versioned (ContractVersion), рост версии additive; удаление/переименование/смена типа поля — breaking (migration + rollout). Значения контактов-оверрайдов никогда не появляются в логах/метриках/ответе (только hasContact*).
  • Logs: UserId, TemplateId, PersonId, IsDefault (без значений контактов); metrics: profile_api_operations_total{operation="createRequisiteTemplate",result}, profile_requisite_templates_created_total, profile_crypto_errors_total; traces — OTel; dashboard profile-overview.

Acceptance Criteria

  • Given инвестор с активной персоной и валидным удостоверением, When POST с корректными name/composition, Then 201 + Location; шаблон Active, IsDefault = false, UsageCount = 0, Version = 1; контакты-оверрайды зашифрованы; composition хранит только id; событие в шину не публикуется (PII не выносится, ADR-0056).
  • Given isDefault = true, When POST, Then новый шаблон — default; флаг снят с прежнего default; Profile.DefaultRequisiteTemplateId синхронизирован в той же транзакции (INV-RQT-5, INV-PRF-5).
  • Given имя дублирует активный шаблон владельца (case-insensitive), When POST, Then PROFILE_TEMPLATE_NAME_TAKEN (409) — INV-RQT-4.
  • Given 20 активных шаблонов, When POST, Then PROFILE_TEMPLATES_LIMIT_EXCEEDED (409) — INV-RQT-7.
  • Given personId или документ состава принадлежат другому владельцу, When POST, Then PROFILE_TEMPLATE_FOREIGN_REFERENCE (400) без раскрытия существования — INV-RQT-1.
  • Given identityDocumentId не паспортного типа или innDocumentId не Inn, When POST, Then PROFILE_TEMPLATE_INVALID_DOCUMENT_TYPE (400) — INV-RQT-2.
  • Given персона или документ состава в архиве, When POST, Then PROFILE_TEMPLATE_REFERENCE_ARCHIVED (409) — INV-RQT-3.
  • Given additionalDocumentIds с дублями / > 10 / пересечением с identity или inn / некорректный contactEmail / contactPhone не E.164, When POST, Then ValidationError (400) на исходных значениях до шифрования.
  • Given ключи шифрования недоступны, When POST, Then PROFILE_CRYPTO_UNAVAILABLE (503), fail-closed.
  • Given конкурентный SetDefault, When два POST с isDefault = true, Then один побеждает, второй — PROFILE_TEMPLATE_DEFAULT_CONFLICT (409), retry.
  • Given повтор с тем же Idempotency-Key, When POST, Then возвращается ранее сохранённый результат без дубля.
  • Given UserType != Investor / заблокирован / TalentSync, When POST, Then PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (403) / PROFILE_USER_BLOCKED (400) / PROFILE_MANAGED_EXTERNALLY (403) соответственно.
  • Given любой успешный ответ, Then значения контактов наружу не отдаются — только hasContactEmail/hasContactPhone; composition — только id.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы