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

Entity: PersonDocumentEntity

Дом полей сущности «Документ персоны» контекста profile (child агрегата PersonEntity). Снапшоты документов в шаблонах реквизитов и заявках на верификацию ссылаются сюда и не переописывают модели полей.

Назначение

PersonDocumentEntity — один документ, привязанный к персоне: паспорт гражданина РФ, РБ, Казахстана, других стран СНГ, иностранный паспорт (Грузия, Армения, ОАЭ и др.), ИНН, а также произвольный кастомный документ. Значения полей документа хранятся в зашифрованном виде (шифрование на уровне приложения): typed-модель полей (строго по типу документа, с ContractVersion) сериализуется в JSON и шифруется целиком в колонку PayloadCipher. Открыто хранятся только неидентифицирующие атрибуты: тип, страна, маска номера, HMAC-хэш номера (для поиска дублей без расшифровки), сканы (fileId сервиса storage) и срок действия.

Сканы/фото документов загружаются в сервис storage (граница — presigned-ссылки); profile хранит только FileId и метаданные файла.

Классификация

ПолеЗначение
Контекстprofile
KindEntity (child агрегата PersonEntity; мутации — только через root-агрегат: добавление / обновление / архив документа)
Source of truthprofile
Таблицаprofile.person_documents
Tenant isolationНе tenanted (ADR-0052); record-level по OwnerUserId (денормализация с root)

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор документа.
PersonIdGuidДаFK fk_person_documents_person_id → profile.persons(id), index ix_person_documents_person_idappПерсона-владелец (root агрегата).
OwnerUserIdGuidДаindex ix_person_documents_owner_user_idapp (денормализация с PersonEntity.OwnerUserId)Владелец аккаунта — для record-level фильтра без join.
TypePersonDocumentTypeДаvarchar(32) (enum как текст), index ix_person_documents_typeappТип документа (см. enum ниже); определяет typed-модель PayloadCipher (INV-DOC-1). Иммутабелен после создания.
StatusPersonDocumentStatusДаvarchar(32) (enum как текст)appActive / Archived.
CountryIdshortДассылка на справочник стран handbook (country.md)appСтрана выдачи документа. На write-path резолвится и валидируется через internal resolve справочника (resolve-references.md): неизвестная/архивная страна → PROFILE_COUNTRY_UNKNOWN (INV-DOC-9). Для RfPassport/RbPassport/KzPassport/Inn страна фиксирована по IsoCode2 (INV-DOC-2); для CisPassport/ForeignPassport/Custom выбирается пользователем.
CountryIsoCode2stringДаchar(2), ISO 3166-1 alpha-2app (денормализация из резолва справочника при записи)Код IsoCode2 страны выдачи, снятый при создании/обновлении документа, — юридический след и источник для снапшотов (реквизиты CRM, верификация) без обращения к справочнику на read-path.
TitlestringДаvarchar(256)app / генерируетсяОтображаемое имя в списках: для стандартных типов — генерируется («Паспорт РФ •• 1234»); для Custom — вводится пользователем (обязателен, INV-DOC-3).
PayloadCipherbyte[]Даbytea, not nullapp (шифрование PII)Зашифрованная JSON-сериализация typed-модели полей документа (модели ниже). Расшифровка — только в self-операциях, снапшоте реквизитов для CRM (internal API) и review верификации.
PayloadContractVersionintДа>= 1appВерсия typed-модели внутри PayloadCipher; миграция версий — lazy при чтении/записи.
KeyVersionintДа>= 1app (шифрование PII)Версия ключа шифрования PayloadCipher.
NumberHashbyte[]Даbytea; unique ux_person_documents_dedupe (person_id, type, number_hash) where status = 'Active' and deleted_at is nullappHMAC-SHA-256 (выделенный ключ PersonalDataCrypto, не ключ шифрования) от нормализованного номера документа (серия+номер без пробелов, upper-case). Поиск дублей без расшифровки (INV-DOC-4).
NumberMaskstringДаvarchar(32)appМаскированный номер для UI-списков и логов: первые 2 + последние 3 символа, остальное * (напр. 45** ***421). Единственная «открытая» проекция номера.
IssueDateDateOnly?Нетapp (копия из payload)Дата выдачи — открытая копия для сортировки списка (сама по себе не идентифицирует).
ExpiresAtDateOnly?Нетindex ix_person_documents_expires_at where not nullapp (копия из payload)Срок действия — для подсветки «истекает» в ЛК; у бессрочных (паспорт РФ, ИНН) — null.
ScanListDocumentScanList (jsonb)Да (может быть пустым списком)jsonb, typed-модель нижеapp + storageСканы документа: контейнер ссылок FileId storage. Не шифруется: PII — в файлах storage, доступ к которым гейтится presigned-ссылками по permission.
CreatedAtDateTimeOffsetДаappСоздание.
CreatedByUserIdGuid?НетappКто создал.
UpdatedAtDateTimeOffsetДаappВремя последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete.
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appВерсия записи (оптимистическая блокировка).

Enum PersonDocumentType → typed-модель payload

ЗначениеTyped-модель (PayloadCipher)Описание
RfPassportRfPassportModelПаспорт гражданина РФ.
RbPassportRbPassportModelПаспорт гражданина Республики Беларусь.
KzPassportKzPassportModelПаспорт / удостоверение личности гражданина Казахстана (с ИИН).
CisPassportCisPassportModelПаспорт гражданина прочих стран СНГ (AM, AZ, UZ, KG, TJ, MD…) — страна в CountryId.
ForeignPassportForeignPassportModelПаспорт иного государства (GE, AE и др.) — страна в CountryId.
InnInnModelИНН (свидетельство о постановке на налоговый учёт, РФ).
CustomCustomDocumentModelКастомный документ с произвольным набором полей.

Unknown enum handling: неизвестное значение Type при чтении — ошибка данных (fail-fast), новые типы добавляются только с новой typed-моделью и миграцией контракта (additive).

Enum PersonDocumentStatus

ЗначениеОписание
ActiveИспользуется в шаблонах/верификации.
ArchivedСкрыт; исторические снапшоты остаются валидными.

Value objects

Отдельные VO не используются: все доменные значения документа — внутри зашифрованного typed payload; открытые скалярные проекции (NumberMask, IssueDate, ExpiresAt) — обычные колонки.

Модели JSONB (versioned)

Payload-модели сериализуются в JSON и шифруются целиком в PayloadCipher (bytea), т.е. это «typed jsonb за шифром»: схема строго типизирована и версионируется PayloadContractVersion, но в БД JSON-операторы к ней неприменимы (осознанно: требование — хранить значения шифрованными). ScanList — обычный plaintext jsonb.

RfPassportModel (payload, ContractVersion = 1) — Type = RfPassport

ПолеТипОбязательностьОграниченияSince version
SeriesstringДа^\d{4}$v1
NumberstringДа^\d{6}$v1
IssuedBystringДа1–512 символов (кем выдан)v1
DepartmentCodestringДа^\d{3}-\d{3}$ (код подразделения)v1
IssueDateDateOnlyДа≤ сегодняv1
BirthPlacestring?Нет≤ 256 символовv1
RegistrationAddressstring?Нет≤ 512 символов (адрес регистрации/прописки)v1

RbPassportModel (payload, ContractVersion = 1) — Type = RbPassport

ПолеТипОбязательностьОграниченияSince version
SeriesstringДа^[A-Z]{2}$ (2 латинские буквы)v1
NumberstringДа^\d{7}$v1
IdentificationNumberstringДа^[0-9A-Z]{14}$ (идентификационный номер РБ)v1
IssuedBystringДа1–512 символовv1
IssueDateDateOnlyДа≤ сегодняv1
ExpiryDateDateOnlyДа> IssueDate; копируется в открытое ExpiresAtv1

KzPassportModel (payload, ContractVersion = 1) — Type = KzPassport

ПолеТипОбязательностьОграниченияSince version
NumberstringДа^[A-Z]\d{8}$ (паспорт) либо ^\d{9}$ (удостоверение)v1
IinstringДа^\d{12}$ (ИИН)v1
IssuedBystringДа1–512 символовv1
IssueDateDateOnlyДа≤ сегодняv1
ExpiryDateDateOnlyДа> IssueDate; → ExpiresAtv1

CisPassportModel (payload, ContractVersion = 1) — Type = CisPassport

ПолеТипОбязательностьОграниченияSince version
CountryIdshortДассылка на справочник стран; IsoCode2 резолвленной страны ∈ AM/AZ/UZ/KG/TJ/MD/TM; дублирует открытое CountryId (консистентность — INV-DOC-2)v1
Seriesstring?Нет≤ 8 символовv1
NumberstringДа1–16 символов, [0-9A-Za-z-]v1
PersonalNumberstring?Нет≤ 20 символов (личный/идентификационный номер, если есть)v1
IssuedBystringДа1–512 символовv1
IssueDateDateOnlyДа≤ сегодняv1
ExpiryDateDateOnly?Нет> IssueDate; → ExpiresAtv1

ForeignPassportModel (payload, ContractVersion = 1) — Type = ForeignPassport

ПолеТипОбязательностьОграниченияSince version
CountryIdshortДассылка на справочник стран (Грузия, ОАЭ и любые прочие); дублирует открытое CountryIdv1
NumberstringДа1–20 символов, [0-9A-Za-z-]v1
IssuedBystring?Нет≤ 512 символовv1
IssueDateDateOnlyДа≤ сегодняv1
ExpiryDateDateOnly?Нет> IssueDate; → ExpiresAtv1
LatinFullNamestring?Нет≤ 256 символов (имя латиницей как в паспорте/MRZ)v1

InnModel (payload, ContractVersion = 1) — Type = Inn

ПолеТипОбязательностьОграниченияSince version
InnstringДа^\d{12}$ (физлицо; контрольные цифры валидируются алгоритмом ФНС)v1
CertificateNumberstring?Нет≤ 32 символов (номер свидетельства)v1
IssueDateDateOnly?Нет≤ сегодняv1

CustomDocumentModel (payload, ContractVersion = 1) — Type = Custom

ПолеТипОбязательностьОграниченияSince version
TitlestringДа1–256 символов; дублируется в открытое Titlev1
Numberstring?Нет≤ 64 символов; если задан — участвует в NumberHash, иначе hash по Titlev1
IssuedBystring?Нет≤ 512 символовv1
IssueDateDateOnly?Нет≤ сегодняv1
ExpiryDateDateOnly?Нет> IssueDate; → ExpiresAtv1
FieldsList<CustomDocumentField>Да (может быть пустым)≤ 30 элементовv1

CustomDocumentField (элемент контейнера)

ПолеТипОбязательностьОграниченияSince version
KeystringДа1–64 символов; уникален в пределах Fieldsv1
ValuestringДа1–1024 символовv1

DocumentScanList (jsonb plaintext, ContractVersion = 1)

ПолеТипОбязательностьОграниченияSince version
ContractVersionintДа= 1v1
ItemsList<DocumentScan>Да (может быть пустым)≤ 10 элементов (INV-DOC-5)v1

DocumentScan (элемент контейнера)

ПолеТипОбязательностьОграниченияSince version
FileIdGuidДаid файла в сервисе storage (скачивание — presigned-ссылка)v1
FileNamestringДа1–256 символовv1
ContentTypestringДаиз списка: image/jpeg, image/png, image/webp, application/pdfv1
SizeByteslongДа1 – 20 971 520 (20 МБ)v1
UploadedAtDateTimeOffsetДаv1

Правило совместимости payload-моделей: изменения — только additive (новые nullable-поля с ростом ContractVersion); чтение старой версии мигрирует в актуальную модель в памяти; breaking — новый PersonDocumentType.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
PersonEntityroot агрегатаPersonId (FK)документ загружается включённым в root (GetById(personId) + include)cascade (архив root архивирует child)
RequisiteTemplateEntityreference (обратная, по id в composition)RequisiteTemplateComposition.*DocumentIdFilter(spec) в guard INV-DOC-6restrict
storage-файлывнешняя границаScanList.Items[].FileIdpresigned-ссылка через storage APIфайлы удаляются retention-политикой storage после архива документа

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-DOC-1 TypedPayloadPayloadCipher расшифровывается строго в модель, соответствующую Type (таблица enum выше); проверка модели — до шифрованияпри добавлении/обновлении документа (единая точка сериализации и шифрования)ValidationError
INV-DOC-2 CountryConsistencyIsoCode2 страны, резолвленной по CountryId через справочник: RfPassport/InnRU, RbPassportBY, KzPassportKZ; CisPassport → ∈ {AM, AZ, UZ, KG, TJ, MD, TM}; для CisPassport/ForeignPassport CountryId внутри payload совпадает с открытым CountryIdпри добавлении/обновлении документа (после резолва INV-DOC-9)PROFILE_DOCUMENT_COUNTRY_MISMATCH
INV-DOC-3 CustomTitleДля Type = Custom Title вводится пользователем (1–256), для прочих — генерируется из типа + NumberMaskпри добавлении документаValidationError
INV-DOC-4 NoDuplicatesАктивный дубль (одинаковый NumberHash при равных PersonId+Type) запрещёнunique partial index ux_person_documents_dedupe + предварительная проверка дубляPROFILE_DOCUMENT_ALREADY_EXISTS
INV-DOC-5 ScansLimit≤ 10 сканов, каждый ≤ 20 МБ, ContentType из белого спискапроверка входа DocumentScanListValidationError
INV-DOC-6 ArchiveNotReferencedActive → Archived запрещён, пока документ указан в активном шаблоне реквизитовпри архиве документа (проверка ссылок в активных шаблонах)PROFILE_DOCUMENT_USED_IN_TEMPLATE
INV-DOC-7 TypeImmutableType не меняется после создания (смена типа = новый документ)тип не принимается операцией обновления
INV-DOC-8 DocumentsLimit≤ 20 активных документов на персону (simplicity-first, агрегат загружается целиком)при добавлении документа (проверка счётчика)PROFILE_DOCUMENTS_LIMIT_EXCEEDED
INV-DOC-9 CountryRefCountryId существует в справочнике стран и не архивен (write-path-резолв через internal resolve handbook, resolve-references.md); CountryIsoCode2 = IsoCode2 резолвленной записи (денормализация при записи)при добавлении/обновлении документаPROFILE_COUNTRY_UNKNOWN

Архив документа (INV-DOC-6) блокирует только ссылка из активного шаблона реквизитов. Документ, попавший лишь в иммутабельный снапшот — снапшот реквизитов сделки CRM или снапшот заявки верификации на момент Submit — архивируется свободно: снапшот содержит собственную копию значений (DocumentSnapshot, verification-request.md) и от статуса исходного документа не зависит.

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
PersonDocumentAddedEventдоменное (поднимает root PersonEntity)добавление документаPersonId, DocumentId, Type, NumberMask
PersonDocumentUpdatedEventдоменноеобновление документаPersonId, DocumentId, список изменённых полей (имена, без значений)
PersonDocumentArchivedEventдоменноеархив документаPersonId, DocumentId
интеграционноеОтдельные Kafka-события документов не публикуютсяИзменение документов отражается инкрементом DocumentsCount в PersonUpsertedKafkaEvent root-а (person.md); содержимое документов не покидает контекст иначе как через internal API снапшота реквизитов

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK (Id); ix_person_documents_person_id; ix_person_documents_owner_user_id; ix_person_documents_type; ux_person_documents_dedupe unique (person_id, type, number_hash) where status='Active' and deleted_at is null; ix_person_documents_expires_at where expires_at is not null (job напоминаний об истечении)
Партиционирование / retentionnone; документы — бессрочно (soft-delete); crypto-erase при удалении PII по регламенту
Concurrencyоптимистическая блокировка записи (правка не должна затирать чужую) + версия root-а
Permissions / PIIВсе значения документа — только в PayloadCipher (AES-256-GCM, PersonalDataCrypto, ключи в Vault; отдельный HMAC-ключ для NumberHash); наружу — только NumberMask/Title; логи — без значений (только DocumentId, Type); self — profile.documents.view-self/profile.documents.manage-self; расшифровка для CRM-сделки — internal API снапшота (requisite-template.md), m2m-actor; забота — profile.personal.view-any, значения маскируются, каждый доступ аудируется
AuditVersion + actor-stamping + доменные события без значений

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

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

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

  • person.md — root агрегата, правила record-level и запрет для TalentSync.
  • Справочник стран: country.md (дом IsoCode2), resolve-references.md — write-path-резолв CountryId (INV-DOC-2/9), включая архивные записи для чтения исторических документов.
  • requisite-template.md — переиспользование документов в сделках/бронировании.
  • verification-request.md — снапшот документа в заявке KYC.
  • ADR-0052, ADR-0056; prototype-mapping.md (features/client-documents, storage presigned-ссылки).