Entity / Aggregate: RequisiteTemplateEntity
Дом полей агрегата «Шаблон реквизитов» контекста profile. Снапшот реквизитов, который CRM копирует в
сделку/бронирование, ссылается сюда и на person.md/person-document.md.
Назначение
RequisiteTemplateEntity — именованный переиспользуемый набор реквизитов для оформления бронирования
и сделки: только инвестор управляет своими контактами, реквизитами и документами, чтобы затем
переиспользовать их как шаблон при бронировании и оформлении сделок. Шаблон связывает персону
(person.md) с конкретными её документами (какой паспорт — удостоверение личности, какой ИНН,
какие дополнительные документы) и опциональными контактами для документов сделки.
Потребитель — контекст crm: при создании брони/сделки инвестор выбирает шаблон, CRM запрашивает
иммутабельный снапшот расшифрованных реквизитов через internal API profile
(будущий документ ../api/internal/get-requisite-snapshot.md)
и хранит копию у себя (документы сделки — владение crm). profile события с PII не публикует.
Выдача снапшота и согласованность (кросс-сервисный контракт). Снапшот выдаётся crm синхронно и
только по активному шаблону (INV-RQT-8); после выдачи копия иммутабельна и принадлежит crm — последующие
правка/архив шаблона или изменение персоны на неё не влияют. Выдача не идемпотентна и намеренно
повторяема: каждый вызов заново собирает снапшот, инкрементирует UsageCount, обновляет LastUsedAt и
порождает обязательный аудит RequisiteTemplateUsedEvent (повтор — это новая выдача, не дубль). Конкурентные
выдачи одного шаблона учитываются как увеличение счётчика; возможная потеря части инкрементов некритична —
UsageCount/LastUsedAt статистические, не финансовые. Об изменении данных персоны, на которую ссылается
шаблон, crm узнаёт из PersonUpserted (person-upserted.md) и сам решает,
предлагать ли повторный запрос снапшота для незавершённой брони; шаблон при этом не переписывается, а ранее
выданные копии остаются валидными.
Классификация
| Поле | Значение |
|---|---|
| Контекст | profile |
| Kind | AggregateRoot |
| Source of truth | profile (снапшоты в сделках CRM — копии на момент использования, source of truth снапшота — crm) |
| Таблица | profile.requisite_templates |
| Tenant isolation | Не tenanted (ADR-0052); record-level по OwnerUserId |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор шаблона. |
OwnerUserId | Guid | Да | index ix_requisite_templates_owner_user_id | app (actor) | Владелец (инвестор). Record-level фильтр. |
Name | string | Да | varchar(128), min 1; unique ux_requisite_templates_owner_name (owner_user_id, lower(name)) where status='Active' and deleted_at is null | app | Имя шаблона в UI (INV-RQT-4). |
PersonId | Guid | Да | FK fk_requisite_templates_person_id → profile.persons(id), index ix_requisite_templates_person_id | app | Персона, на которую оформляются документы сделки. |
Status | RequisiteTemplateStatus | Да | varchar(32) (enum как текст) | app | Active / Archived. |
IsDefault | bool | Да | default false; unique ux_requisite_templates_owner_default (owner_user_id) where is_default and status='Active' and deleted_at is null | app | Шаблон по умолчанию, подставляемый в форму бронирования (INV-RQT-5). |
Composition | RequisiteTemplateComposition (jsonb) | Да | typed jsonb, модель ниже | app | Состав шаблона: ссылки на документы персоны (только id — не PII, хранится открыто). |
ContactEmailCipher | byte[]? | Нет | bytea | app (шифрование PII) | Контактный email для документов сделки, если отличается от контактов аккаунта (например, email супруги). Plaintext: формат email, ≤ 256 символов (валидация до шифрования). |
ContactPhoneCipher | byte[]? | Нет | bytea | app (шифрование PII) | Контактный телефон для документов сделки (E.164, ≤ 32 символов до шифрования). |
KeyVersion | int | Да | >= 1 | app | Версия ключа шифрования cipher-полей. |
LastUsedAt | DateTimeOffset? | Нет | index ix_requisite_templates_last_used_at | app (internal API снапшота) | Момент последнего использования в брони/сделке — сортировка «недавние» в UI. |
UsageCount | int | Да | >= 0, default 0 | app | Сколько раз запрошен снапшот для брони/сделки. |
CreatedAt | DateTimeOffset | Да | — | app | Создание. |
CreatedByUserId | Guid? | Нет | — | app | Кто создал. |
UpdatedAt | DateTimeOffset | Да | — | app | Время последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete. |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Версия записи (оптимистическая блокировка). |
Enum RequisiteTemplateStatus
| Значение | Описание |
|---|---|
Active | Доступен для выбора в бронировании/сделке. |
Archived | Скрыт; ранее созданные снапшоты в CRM не затрагиваются. |
Value objects
VO не используются: контакты — отдельные шифрованные колонки (не структурные), состав — typed jsonb.
Модели JSONB (versioned)
RequisiteTemplateComposition (jsonb, ContractVersion = 1)
Хранится открыто (plaintext jsonb): содержит только идентификаторы документов, не значения.
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
ContractVersion | int | Да | = 1 | v1 |
IdentityDocumentId | Guid | Да | id PersonDocumentEntity c Type ∈ {RfPassport, RbPassport, KzPassport, CisPassport, ForeignPassport} (INV-RQT-2) | v1 |
InnDocumentId | Guid? | Нет | id PersonDocumentEntity c Type = Inn | v1 |
AdditionalDocumentIds | List<Guid> | Да (может быть пустым) | ≤ 10 элементов; каждый — id документа персоны любого типа; без дублей и без повторов IdentityDocumentId/InnDocumentId | v1 |
Совместимость: additive-изменения с ростом ContractVersion; чтение старых версий — lazy-миграция в памяти.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
PersonEntity | reference | PersonId (FK) | персона с документами загружается при валидации состава и построении снапшота | restrict (персона не архивируется при активном шаблоне — INV-PRS-4 в person.md) |
PersonDocumentEntity | reference (по id в Composition) | Composition.*DocumentId | документы приходят включёнными в агрегат персоны; принадлежность и статус проверяются в памяти | restrict (INV-DOC-6 в person-document.md) |
ProfileEntity | reference | OwnerUserId → profiles.id; обратная — profiles.default_requisite_template_id | профиль загружается в guard-ах | restrict |
| Снапшот в CRM | копия (вне контекста) | RequisiteSnapshot в сделке crm | internal API по запросу CRM | не связаны после копирования |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-RQT-1 SameOwner | PersonId и все документы из Composition принадлежат OwnerUserId (person.OwnerUserId == actor; каждый documentId ∈ документам этой персоны) | при создании/изменении шаблона (сверка в памяти) | PROFILE_TEMPLATE_FOREIGN_REFERENCE |
| INV-RQT-2 IdentityDocType | IdentityDocumentId ссылается на активный документ паспортного типа; InnDocumentId (если задан) — на активный Inn | при создании/изменении шаблона | PROFILE_TEMPLATE_INVALID_DOCUMENT_TYPE |
| INV-RQT-3 ActiveReferences | Все документы состава и персона — Status = Active на момент создания/изменения шаблона | при создании/изменении шаблона | PROFILE_TEMPLATE_REFERENCE_ARCHIVED |
| INV-RQT-4 UniqueName | Имя шаблона уникально среди активных шаблонов владельца (case-insensitive) | unique partial index + предварительная проверка имени | PROFILE_TEMPLATE_NAME_TAKEN |
| INV-RQT-5 SingleDefault | Не более одного IsDefault = true среди активных шаблонов владельца; установка по умолчанию снимает флаг с предыдущего в той же транзакции | unique partial index + установка шаблона по умолчанию (два обновления в одной транзакции) | PROFILE_TEMPLATE_DEFAULT_CONFLICT (конфликт индекса — retry) |
| INV-RQT-6 InvestorOnly | Управление шаблонами — только Profile.UserType = Investor, permission profile.requisites.manage-self | guard investor-функций | PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE |
| INV-RQT-7 TemplatesLimit | ≤ 20 активных шаблонов на владельца | при создании шаблона (проверка счётчика) | PROFILE_TEMPLATES_LIMIT_EXCEEDED |
| INV-RQT-8 SnapshotFromActive | Снапшот для CRM выдаётся только по Status = Active (архивный шаблон в новые сделки не попадает) | при выдаче снапшота (internal API) | PROFILE_TEMPLATE_ARCHIVED |
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|---|---|---|
RequisiteTemplateCreatedEvent | доменное | Создание | Id, OwnerUserId, PersonId |
RequisiteTemplateChangedEvent | доменное | Изменение состава/имени/контактов/default | Id, OwnerUserId, список изменённых полей (имена) |
RequisiteTemplateUsedEvent | доменное | Выдача снапшота CRM | Id, OwnerUserId, RequestedByService |
RequisiteTemplateArchivedEvent | доменное | Архив | Id, OwnerUserId |
| — | интеграционное | Не публикуются | Simplicity-first (ADR-0056): CRM тянет снапшот синхронно через internal API в момент оформления брони/сделки; событийная репликация шаблонов не нужна и означала бы вынос PII в Kafka |
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK (Id); ix_requisite_templates_owner_user_id; ix_requisite_templates_person_id; ux_requisite_templates_owner_name unique (owner_user_id, lower(name)) where status='Active' and deleted_at is null; ux_requisite_templates_owner_default unique (owner_user_id) where is_default and status='Active' and deleted_at is null; ix_requisite_templates_last_used_at |
| Партиционирование / retention | none; бессрочно (soft-delete) |
| Concurrency | оптимистическая блокировка (правка не должна затирать чужую); установка шаблона по умолчанию — два обновления в одной транзакции под unique-индексом |
| Permissions / PII | Composition — только id (не PII, открыто); контакты-оверрайды — шифрованные (PersonalDataCrypto); self — profile.requisites.manage-self; выдача расшифрованного снапшота — только internal API для m2m-actor crm (запрос обязан содержать OwnerUserId инициатора сделки; сверка владения) + аудит выдачи (RequisiteTemplateUsedEvent); в логах — только TemplateId/PersonId |
| Audit | Version + actor-stamping + UsageCount/LastUsedAt + RequisiteTemplateUsedEvent на каждую выдачу |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: PersonEntity, Entity / Aggregate: ProfileEntity, Entity / Aggregate: VerificationRequestEntity, Entity: PersonDocumentEntity
- API (8): GET /api/v1/profile/requisite-templates — Список шаблонов реквизитов (listRequisiteTemplates), POST /api/v1/internal/profile/requisite-snapshot — Снапшот реквизитов для CRM (getRequisiteSnapshot + RegisterUsage), POST /api/v1/profile/requisite-templates — Создание шаблона реквизитов (createRequisiteTemplate), POST /api/v1/profile/requisite-templates/{templateId}/archive — Архивация шаблона реквизитов (archiveRequisiteTemplate), POST /api/v1/profile/requisite-templates/{templateId}/set-default — Шаблон по умолчанию (setDefaultTemplate), POST /api/v1/profile/verification — Создание черновика заявки KYC (createVerificationRequest), Profile API — реестр операций областей public / internal / admin, PUT /api/v1/profile/requisite-templates/{templateId} — Изменение шаблона реквизитов (updateRequisiteTemplate)
- Use Cases (1): UC-PRF-004. Создание шаблона реквизитов и его использование в бронировании (CRM)
- События (1): Event Contract: pin.profile.person-upserted (PersonUpsertedKafkaEvent)
- Фоновые задачи (1): Background Job: ReEncryptRotationJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Profile Service (Pin.Profile)
Связанные документы
- person.md, person-document.md, profile.md.
- CRM-потребитель:
docs/06-services/crm/(бронирование/сделка используют реквизиты — формы брони прототипа, prototype-mapping.md: «Бронирование: опциональное отчество, форма неполного профиля»); контракт снапшота — будущий документ ../api/internal/get-requisite-snapshot.md. - Верификация из шаблона: verification-request.md.
- ADR-0052, ADR-0056; permissions — permission.md.