From 93bcbca12ce36de541d6832d6528068b6d19de1e Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 14 May 2026 00:45:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(fansy-store):=20DDL=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=8E=D1=89=D0=B5=D0=B9=20=D0=91=D0=94?= =?UTF-8?q?=20+=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20Fansy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/fansy-contract/v1/ddl/000__roles.sql: роли fansy_etl, bj_reader, bj_migrator - docs/fansy-contract/v1/ddl/001__schemas.sql: схемы fansy_staging и fansy с грантами - docs/fansy-contract/v1/ddl/002__working.sql: рабочая схема (participants, securities, clients, client_documents, iia_contracts, settlement_requisites, depo_accounts, portfolios, etl_errors) - docs/fansy-contract/v1/ddl/003__staging.sql: staging-зеркало с loaded_at и сниженными ограничениями - docs/fansy-contract/v1/ddl/004__seed_participants.sql: предзаполнение справочника (НРД, БКС 5406121446, Ренессанс 7709258228, Альфа-Банк 7728168971) - docs/fansy-contract/v1/data-dictionary.md: семантика каждого поля - docs/fansy-contract/v1/etl-requirements.md: требования к ETL (UPSERT в staging, SLA свежести по таблицам, обработка ошибок) - docs/fansy-contract/v1/examples/example-claim.md: SQL-запросы для формирования M2MTransferRequest - docs/fansy-contract/v1/examples/seed-data.sql: 5 тестовых клиентов + портфели + договоры - migrations/fansy-store/: рабочие копии миграций Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/fansy-contract/v1/README.md | 73 ++++-- docs/fansy-contract/v1/data-dictionary.md | 123 ++++++++++ docs/fansy-contract/v1/ddl/000__roles.sql | 26 ++ docs/fansy-contract/v1/ddl/001__schemas.sql | 23 ++ docs/fansy-contract/v1/ddl/002__working.sql | 231 ++++++++++++++++++ docs/fansy-contract/v1/ddl/003__staging.sql | 109 +++++++++ .../v1/ddl/004__seed_participants.sql | 56 +++++ docs/fansy-contract/v1/etl-requirements.md | 87 +++++++ .../v1/examples/example-claim.md | 118 +++++++++ docs/fansy-contract/v1/examples/seed-data.sql | 90 +++++++ docs/tasks/README.md | 2 +- migrations/fansy-store/000__roles.sql | 26 ++ migrations/fansy-store/001__schemas.sql | 23 ++ migrations/fansy-store/002__working.sql | 231 ++++++++++++++++++ migrations/fansy-store/003__staging.sql | 109 +++++++++ .../fansy-store/004__seed_participants.sql | 56 +++++ 16 files changed, 1357 insertions(+), 26 deletions(-) create mode 100644 docs/fansy-contract/v1/data-dictionary.md create mode 100644 docs/fansy-contract/v1/ddl/000__roles.sql create mode 100644 docs/fansy-contract/v1/ddl/001__schemas.sql create mode 100644 docs/fansy-contract/v1/ddl/002__working.sql create mode 100644 docs/fansy-contract/v1/ddl/003__staging.sql create mode 100644 docs/fansy-contract/v1/ddl/004__seed_participants.sql create mode 100644 docs/fansy-contract/v1/etl-requirements.md create mode 100644 docs/fansy-contract/v1/examples/example-claim.md create mode 100644 docs/fansy-contract/v1/examples/seed-data.sql create mode 100644 migrations/fansy-store/000__roles.sql create mode 100644 migrations/fansy-store/001__schemas.sql create mode 100644 migrations/fansy-store/002__working.sql create mode 100644 migrations/fansy-store/003__staging.sql create mode 100644 migrations/fansy-store/004__seed_participants.sql diff --git a/docs/fansy-contract/v1/README.md b/docs/fansy-contract/v1/README.md index 0d76884..31dbc85 100644 --- a/docs/fansy-contract/v1/README.md +++ b/docs/fansy-contract/v1/README.md @@ -1,33 +1,56 @@ # docs/fansy-contract/v1 — контракт данных с командой Fansy ETL Fansy → принимающая БД (`fansy-store`) реализует **другая команда -разработки**. С нашей стороны: +разработки**. С нашей стороны зафиксирован контракт: схема таблиц, +индексы, миграции, требования к выгрузке и тестовые данные. -1. Спроектировать таблицы по требованиям документации НРД к данным M2M. -2. Передать команде Fansy DDL и контракт данных. -3. Согласовать тип load (UPSERT в staging), окна обновления, SLA на - свежесть данных. -4. Не давать ETL-роли DDL-прав в принимающей схеме. +## Состав каталога -Состав каталога (создаём в M1, отправляем в начале M2): +- **`ddl/`** — SQL-миграции PostgreSQL: + - `000__roles.sql` — роли `fansy_etl` (ETL Fansy), `bj_reader` + (наши сервисы), `bj_migrator` (миграции). + - `001__schemas.sql` — две схемы: `fansy_staging` (куда пишет ETL) и + `fansy` (рабочая, для нашего чтения). Гранты по ролям. + - `002__working.sql` — рабочие таблицы: `participants`, `securities`, + `clients`, `client_documents`, `iia_contracts`, + `settlement_requisites`, `depo_accounts`, `portfolios`, + `etl_errors`. + - `003__staging.sql` — staging-зеркало рабочих таблиц с полем + `loaded_at` и сниженными ограничениями. + - `004__seed_participants.sql` — предзаполнение справочника + участников: НРД, БКС (5406121446), Ренессанс (7709258228), + Альфа-Банк (7728168971). +- **`data-dictionary.md`** — семантика каждого поля. +- **`etl-requirements.md`** — требования к процессу выгрузки от + команды Fansy: подключение, тип load (UPSERT в staging), + SLA свежести по таблицам, обработка ошибок, окна простоя, ПДн. +- **`examples/`**: + - `example-claim.md` — какие данные `m2m-core` тянет из БД для + одной типовой M2M-заявки (с конкретными SQL). + - `seed-data.sql` — 5 тестовых клиентов, портфели, договоры — + основа для приёмочного теста. -- `ddl/` — `*.sql` миграции PostgreSQL для всех таблиц. -- `data-dictionary.md` — семантика каждого поля (источник в Fansy, - nullable, единицы, примеры). -- `etl-requirements.md` — требования к процессу выгрузки: тип load, - расписание, способ записи, окна простоя, обработка ошибок, - конфиденциальность. -- `examples/` — пример заявки M2M «end-to-end», 5–10 тестовых клиентов - и заявок для совместного приёмочного теста. +## Рабочие копии миграций -Минимальный набор таблиц (см. план): +Те же файлы лежат в `migrations/fansy-store/` — оттуда они +применяются при инициализации БД сервиса. -- Депоненты / клиенты. -- Документы инвестора (`IdentityDocumentCodeEnum`). -- ИИС-договоры (`IIAContractTypeEnum ∈ {T12, T03}`). -- Депо-счета и разделы (`AccountId`, `SectionId`, `DeponentCode`). -- Реквизиты расчётов (ИНН депозитария). -- Портфели и остатки (Whole / Fractional, `IsolationStatus = SGDN`). -- Справочник ЦБ (`SecurityCode`, `ISIN`, `Classification`, `Category`). -- Контрагенты-участники сервиса MOST (Справочник пользователей). -- Audit / staging-таблицы для каждой основной. +## Порядок согласования + +1. Передать команде Fansy ссылку на эту папку (тег `fansy-contract-v1`). +2. Обсудить с ними SLA, окна простоя, тип load. +3. По согласовании — дать им учётку с ролью `fansy_etl` и подсеть для + доступа. +4. Запустить совместный приёмочный тест на `seed-data.sql`. +5. Изменения контракта — через новую папку `v2/` с changelog'ом, без + правки `v1/`. + +## Принципы + +- Имена таблиц/колонок — `snake_case` английский. +- Комментарии к таблицам и важным колонкам — на русском + через `COMMENT ON ... IS '...'`. +- Все timestamp — `timestamptz` в UTC. +- DDL-права только у `bj_migrator`, у `fansy_etl` нет. +- ETL пишет ТОЛЬКО в `fansy_staging.*`. Перелив в `fansy.*` — на нашей + стороне после валидации. diff --git a/docs/fansy-contract/v1/data-dictionary.md b/docs/fansy-contract/v1/data-dictionary.md new file mode 100644 index 0000000..35f270b --- /dev/null +++ b/docs/fansy-contract/v1/data-dictionary.md @@ -0,0 +1,123 @@ +# Data Dictionary — fansy-store v1 + +Семантика полей рабочей схемы `fansy`. Структура staging-схемы +`fansy_staging` повторяет её один-к-одному, плюс поле `loaded_at` и +отсутствие части ограничений (валидация — при переливе). + +Обозначения: `?` — nullable; `!` — обязательное. + +## participants — справочник контрагентов M2M + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| inn | varchar(10) | ! | ИНН юрлица, PK | `client_master.inn` | `7702165310` | +| ogrn | varchar(15) | ? | ОГРН | `client_master.ogrn` | `1027739132563` | +| full_name_rus | text | ! | Полное наименование на русском | `client_master.full_name` | `НКО АО НРД` | +| short_name_rus | text | ? | Короткое наименование | `client_master.short_name` | `НРД` | +| display_name_rus | text | ! | Отображаемое имя для UI | `client_master.display_name` | `НРД` | +| full_name_eng | text | ? | Полное наименование на английском | `client_master.full_name_en` | `National Settlement Depository` | +| short_name_eng | text | ? | Короткое английское | `client_master.short_name_en` | `NSD` | +| display_name_eng | text | ? | Английское display | `client_master.display_name_en` | `NSD` | +| depository_participant_code | varchar(12) | ? | Код участника M2M (депозитарий) | `m2m_codes.dep_code` | `MC0010300000` | +| broker_participant_code | varchar(12) | ? | Код участника M2M (брокер) | `m2m_codes.brk_code` | `MC0079200001` | +| is_available_for_m2m | boolean | ! | Готовность к приёму M2M | `m2m_codes.is_active` | `true` | +| comment | text | ? | Свободный комментарий | — | — | +| created_at, updated_at | timestamptz | ! | Авто | — | — | + +## securities — справочник ЦБ + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| security_code | char(12) | ! | Идентификатор ЦБ в системе НРД, PK | `security_master.nsd_code` | `MM0766162534` | +| isin | char(12) | ? | ISIN | `security_master.isin` | `RU0007661625` | +| classification | varchar(4) | ? | `BOND` (облигация), `SHAR` (акция), `MFUN` (ПИФ) | `security_master.type_code` | `SHAR` | +| category | varchar(4) | ? | `ORDN`/`PREF`/`UKWN` | `security_master.category` | `ORDN` | +| security_type | varchar(256) | ? | Текстовое описание типа | `security_master.type_text` | `Акция обыкновенная` | +| security_series | text | ? | Серия выпуска (для облигаций) | `security_master.series` | `01` | +| reg_number | varchar(256) | ? | Регистрационный номер выпуска / правил ДУ ПИФ | `security_master.reg_number` | `1-01-00010-A` | +| fund_class | varchar(120) | ? | Класс паёв ПИФ | `security_master.fund_class` | `A` | +| display_name | text | ! | Отображаемое имя для UI | `security_master.display` | `Сбербанк ао` | + +## clients — депоненты-физлица + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| id | uuid | ! | PK, генерируется БД | `customer.uuid` | — | +| inn | varchar(12) | ? | ИНН (10 цифр юрлицо, 12 цифр физлицо) | `customer.inn` | `771234567890` | +| last_name | varchar(50) | ! | Фамилия | `customer.last_name` | `Иванов` | +| first_name | varchar(50) | ! | Имя | `customer.first_name` | `Иван` | +| middle_name | varchar(50) | ? | Отчество | `customer.middle_name` | `Иванович` | +| birth_date | date | ? | Дата рождения | `customer.birth_date` | `1980-01-15` | + +## client_documents — документы инвестора + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| id | uuid | ! | PK | — | — | +| client_id | uuid | ! | FK на `clients.id` | `customer_doc.customer_uuid` | — | +| document_type | varchar(2) | ! | Код документа по справочнику НРД (01..91) | `customer_doc.type_code` | `21` | +| series | text | ? | Серия (без пробелов) | `customer_doc.series` | `4512` | +| number | text | ! | Номер (без пробелов) | `customer_doc.number` | `654321` | +| issued_at | date | ? | Дата выдачи | `customer_doc.issued_at` | `2010-05-12` | +| issuer | text | ? | Кем выдан | `customer_doc.issuer` | `ОУФМС России` | + +## iia_contracts — договоры ИИС + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| id | uuid | ! | PK | — | — | +| client_id | uuid | ! | FK на `clients.id` | — | — | +| agreement_type | varchar(3) | ! | `T12` (ИИС-1/ИИС-2) или `T03` (ИИС-3) | `iia.type` | `T03` | +| agreement_number | varchar(128) | ! | Номер договора | `iia.number` | `ИИС78/2024` | +| agreement_date | date | ! | Дата заключения | `iia.signed_at` | `2026-01-15` | +| broker_inn | varchar(10) | ! | ИНН брокера, ведущего ИИС | `iia.broker_inn` | `0707083893` | + +## settlement_requisites — реквизиты депозитариев + +| Поле | Тип | Обяз. | Описание | +|---|---|---|---| +| id | uuid | ! | PK | +| inn | varchar(10) | ! | ИНН депозитария, UNIQUE | +| display_name | text | ! | Отображаемое имя | + +## depo_accounts — счета депо + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| id | uuid | ! | PK | — | — | +| client_id | uuid | ! | FK на `clients.id` | — | — | +| deponent_code | varchar(50) | ! | Код депонента у депозитария | `depo.deponent_code` | `DP789456` | +| account_id | varchar(50) | ! | Номер счёта депо | `depo.account_id` | `31MC0021900000F01` | +| section_id | varchar(50) | ! | Номер раздела счёта | `depo.section_id` | `P001` | +| depository_inn | varchar(10) | ! | ИНН депозитария | `depo.depository_inn` | `7702070139` | +| is_active | boolean | ! | Активен ли счёт | `depo.is_active` | `true` | +| is_trading | boolean | ! | Торговый раздел | `depo.is_trading` | `true` | + +Уникальность по тройке `(deponent_code, account_id, section_id)`. + +## portfolios — портфели и остатки ЦБ + +| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример | +|---|---|---|---|---|---| +| id | uuid | ! | PK | — | — | +| client_id | uuid | ! | FK на `clients.id` | — | — | +| depo_account_id | uuid | ! | FK на `depo_accounts.id` | — | — | +| security_code | char(12) | ! | FK на `securities.security_code` | — | `MM0766162534` | +| isin | char(12) | ? | Кэш ISIN из securities | — | `RU0007661625` | +| quantity_whole | numeric(38,0) | ? | Целое количество (для акций/облигаций) | `position.qty_whole` | `1500` | +| quantity_fractional | numeric(38,16) | ? | Дробное (для паёв) | `position.qty_fract` | `2500.7500000000000000` | +| isolation_status | varchar(4) | ! | Всегда `SGDN` | — | `SGDN` | +| valued_at | timestamptz | ! | На какой момент актуально | `position.valued_at` | `2026-03-02T11:30:00Z` | + +Должно быть заполнено ровно одно из (`quantity_whole`, `quantity_fractional`). + +## etl_errors — журнал ошибок ETL + +| Поле | Тип | Обяз. | Описание | +|---|---|---|---| +| id | bigserial | ! | PK | +| source_table | text | ! | Таблица в Fansy | +| source_pk | text | ? | PK записи в Fansy | +| payload | jsonb | ? | Сама запись для ретрая | +| error_message | text | ! | Сообщение об ошибке | +| created_at | timestamptz | ! | Когда зафиксирована | diff --git a/docs/fansy-contract/v1/ddl/000__roles.sql b/docs/fansy-contract/v1/ddl/000__roles.sql new file mode 100644 index 0000000..9642883 --- /dev/null +++ b/docs/fansy-contract/v1/ddl/000__roles.sql @@ -0,0 +1,26 @@ +-- 000__roles.sql +-- Роли для принимающей БД fansy-store. +-- Запускать первым, отдельно от структурных миграций. +-- Пароли проставляются администратором БД через ALTER ROLE. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fansy_etl') THEN + CREATE ROLE fansy_etl LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE fansy_etl IS + 'Роль команды Fansy для ETL: INSERT/UPDATE/SELECT в схему fansy_staging. DDL-прав нет.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_reader') THEN + CREATE ROLE bj_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE bj_reader IS + 'Роль сервисов Bridge-and-Join-s (m2m-core, lk-gateway) для чтения схемы fansy.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_migrator') THEN + CREATE ROLE bj_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE bj_migrator IS + 'Роль с DDL-правами для миграций. Только эта роль может CREATE/ALTER/DROP.'; + END IF; +END +$$; diff --git a/docs/fansy-contract/v1/ddl/001__schemas.sql b/docs/fansy-contract/v1/ddl/001__schemas.sql new file mode 100644 index 0000000..7ed23b8 --- /dev/null +++ b/docs/fansy-contract/v1/ddl/001__schemas.sql @@ -0,0 +1,23 @@ +-- 001__schemas.sql +-- Две схемы: fansy_staging (куда пишет ETL Fansy) и fansy (рабочая, +-- куда переливаются данные после валидации). + +CREATE SCHEMA IF NOT EXISTS fansy_staging AUTHORIZATION bj_migrator; +COMMENT ON SCHEMA fansy_staging IS + 'Staging-схема. ETL Fansy делает UPSERT в эти таблицы. Сюда же пишутся ошибки выгрузки.'; + +CREATE SCHEMA IF NOT EXISTS fansy AUTHORIZATION bj_migrator; +COMMENT ON SCHEMA fansy IS + 'Рабочая схема. Сюда переливаются актуальные данные триггерами или процедурами после валидации staging.'; + +-- Права по ролям. DDL-права остаются только у владельца bj_migrator. +GRANT USAGE ON SCHEMA fansy_staging TO fansy_etl; +GRANT USAGE ON SCHEMA fansy TO bj_reader; + +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging + GRANT SELECT, INSERT, UPDATE ON TABLES TO fansy_etl; +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO fansy_etl; + +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy + GRANT SELECT ON TABLES TO bj_reader; diff --git a/docs/fansy-contract/v1/ddl/002__working.sql b/docs/fansy-contract/v1/ddl/002__working.sql new file mode 100644 index 0000000..77478fa --- /dev/null +++ b/docs/fansy-contract/v1/ddl/002__working.sql @@ -0,0 +1,231 @@ +-- 002__working.sql +-- Рабочая схема fansy. Данные сюда переливаются из fansy_staging после +-- валидации. Сервисы Bridge-and-Join-s читают только эту схему. + +SET search_path TO fansy, public; + +-- --------------------------------------------------------------------- +-- participants — справочник участников сервиса MOST (контрагенты M2M) +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS participants ( + inn varchar(10) PRIMARY KEY, + ogrn varchar(15), + full_name_rus text NOT NULL, + short_name_rus text, + display_name_rus text NOT NULL, + full_name_eng text, + short_name_eng text, + display_name_eng text, + depository_participant_code varchar(12), + broker_participant_code varchar(12), + is_available_for_m2m boolean NOT NULL DEFAULT false, + comment text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn ~ '^[0-9]{10}$'), + CHECK (depository_participant_code IS NULL OR depository_participant_code ~ '^[A-Z0-9]+$'), + CHECK (broker_participant_code IS NULL OR broker_participant_code ~ '^[A-Z0-9]+$') +); +COMMENT ON TABLE participants IS 'Справочник участников сервиса MOST: депозитарии и брокеры, между которыми идут M2M-переводы.'; +COMMENT ON COLUMN participants.inn IS 'ИНН юрлица (10 цифр), первичный ключ.'; +COMMENT ON COLUMN participants.depository_participant_code IS 'Код участника M2M на стороне депозитария (для DepositoryPlace в M2MTransferHandbook).'; +COMMENT ON COLUMN participants.broker_participant_code IS 'Код участника M2M на стороне брокера (для BrokerPlace).'; +COMMENT ON COLUMN participants.is_available_for_m2m IS 'Готовность участника принимать/отправлять M2M-сообщения (включается после подписания НРД-договора).'; + +CREATE INDEX IF NOT EXISTS idx_participants_dep_code ON participants(depository_participant_code) WHERE depository_participant_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_participants_brk_code ON participants(broker_participant_code) WHERE broker_participant_code IS NOT NULL; + +-- --------------------------------------------------------------------- +-- securities — справочник ценных бумаг +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS securities ( + security_code char(12) PRIMARY KEY, + isin char(12), + classification varchar(4), + category varchar(4), + security_type varchar(256), + security_series text, + reg_number varchar(256), + fund_class varchar(120), + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (security_code ~ '^[0-9A-Z_/-]+$'), + CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'), + CHECK (classification IS NULL OR classification IN ('BOND', 'SHAR', 'MFUN')), + CHECK (category IS NULL OR category IN ('ORDN', 'PREF', 'UKWN')) +); +COMMENT ON TABLE securities IS 'Справочник ценных бумаг с их идентификаторами и классификацией.'; +COMMENT ON COLUMN securities.security_code IS 'Идентификатор ценной бумаги в системе НРД (XSD SecurityCodeType).'; +COMMENT ON COLUMN securities.classification IS 'Тип ценной бумаги: BOND (облигация), SHAR (акция), MFUN (ПИФ).'; +COMMENT ON COLUMN securities.category IS 'Категория акций: ORDN (обыкновенные), PREF (привилегированные), UKWN (неизвестно).'; +COMMENT ON COLUMN securities.reg_number IS 'Регистрационный номер выпуска (для акций и облигаций) или регномер правил доверительного управления ПИФ.'; +COMMENT ON COLUMN securities.fund_class IS 'Класс паёв ПИФа (если применимо).'; + +CREATE INDEX IF NOT EXISTS idx_securities_isin ON securities(isin) WHERE isin IS NOT NULL; + +-- --------------------------------------------------------------------- +-- clients — депоненты / инвесторы +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS clients ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inn varchar(12), + last_name varchar(50) NOT NULL, + first_name varchar(50) NOT NULL, + middle_name varchar(50), + birth_date date, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn IS NULL OR inn ~ '^[0-9]{10,12}$') +); +COMMENT ON TABLE clients IS 'Депоненты-физлица. Привязка к документам и счетам — через FK из дочерних таблиц.'; +COMMENT ON COLUMN clients.inn IS 'ИНН физлица (12 цифр) или организации (10 цифр), опционально.'; +COMMENT ON COLUMN clients.last_name IS 'Фамилия (XSD String50, обязательно).'; +COMMENT ON COLUMN clients.first_name IS 'Имя (XSD String50, обязательно).'; +COMMENT ON COLUMN clients.middle_name IS 'Отчество (XSD String50, опционально).'; + +CREATE INDEX IF NOT EXISTS idx_clients_inn ON clients(inn) WHERE inn IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_clients_lastname ON clients(last_name, first_name); + +-- --------------------------------------------------------------------- +-- client_documents — документы инвестора +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS client_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + document_type varchar(2) NOT NULL, + series text, + number text NOT NULL, + issued_at date, + issuer text, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (document_type IN ( + '01','02','03','04','05','06','07','09','10','11','12','13','14', + '21','22','23','26','27','91' + )), + CHECK (series IS NULL OR series ~ '^\S+$'), + CHECK (number ~ '^\S+$') +); +COMMENT ON TABLE client_documents IS 'Документы, удостоверяющие личность инвестора. Коды по справочнику НРД (XSD IdentityDocumentCodeEnum).'; +COMMENT ON COLUMN client_documents.document_type IS 'Код вида документа (01..91, см. XSD НРД).'; +COMMENT ON COLUMN client_documents.series IS 'Серия документа (без пробелов).'; +COMMENT ON COLUMN client_documents.number IS 'Номер документа (без пробелов, обязательно).'; + +CREATE INDEX IF NOT EXISTS idx_client_documents_client ON client_documents(client_id); + +-- --------------------------------------------------------------------- +-- iia_contracts — договоры ИИС +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS iia_contracts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + agreement_type varchar(3) NOT NULL, + agreement_number varchar(128) NOT NULL, + agreement_date date NOT NULL, + broker_inn varchar(10) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (agreement_type IN ('T12', 'T03')), + CHECK (broker_inn ~ '^[0-9]{10}$') +); +COMMENT ON TABLE iia_contracts IS 'Договоры на ведение ИИС инвестора.'; +COMMENT ON COLUMN iia_contracts.agreement_type IS 'Тип договора: T12 — ИИС-1/ИИС-2 (старый формат); T03 — ИИС-3 (новый).'; +COMMENT ON COLUMN iia_contracts.broker_inn IS 'ИНН брокера, с которым заключён договор ИИС.'; + +CREATE INDEX IF NOT EXISTS idx_iia_contracts_client ON iia_contracts(client_id); +CREATE INDEX IF NOT EXISTS idx_iia_contracts_broker ON iia_contracts(broker_inn); + +-- --------------------------------------------------------------------- +-- settlement_requisites — реквизиты расчётов (депозитарии) +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS settlement_requisites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inn varchar(10) NOT NULL UNIQUE, + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn ~ '^[0-9]{10}$') +); +COMMENT ON TABLE settlement_requisites IS 'Реквизиты передающего и принимающего депозитариев (XSD SettlementRequisitesType — содержит только ИНН).'; + +-- --------------------------------------------------------------------- +-- depo_accounts — депо-счета и разделы +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS depo_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE RESTRICT, + deponent_code varchar(50) NOT NULL, + account_id varchar(50) NOT NULL, + section_id varchar(50) NOT NULL, + depository_inn varchar(10) NOT NULL, + is_active boolean NOT NULL DEFAULT true, + is_trading boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (depository_inn ~ '^[0-9]{10}$'), + UNIQUE (deponent_code, account_id, section_id) +); +COMMENT ON TABLE depo_accounts IS 'Счета депо инвестора и их разделы у различных депозитариев.'; +COMMENT ON COLUMN depo_accounts.deponent_code IS 'Код депонента у конкретного депозитария (XSD SettlementDepositoryLocationType.DeponentCode).'; +COMMENT ON COLUMN depo_accounts.account_id IS 'Номер счёта депо (XSD AccountIdType).'; +COMMENT ON COLUMN depo_accounts.section_id IS 'Номер раздела счёта депо.'; +COMMENT ON COLUMN depo_accounts.is_trading IS 'Признак торгового раздела (для отделения от изолированных).'; + +CREATE INDEX IF NOT EXISTS idx_depo_accounts_client ON depo_accounts(client_id); +CREATE INDEX IF NOT EXISTS idx_depo_accounts_deponent ON depo_accounts(deponent_code); + +-- --------------------------------------------------------------------- +-- portfolios — портфели и остатки ЦБ +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS portfolios ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + depo_account_id uuid NOT NULL REFERENCES depo_accounts(id) ON DELETE CASCADE, + security_code char(12) NOT NULL REFERENCES securities(security_code), + isin char(12), + quantity_whole numeric(38, 0), + quantity_fractional numeric(38, 16), + isolation_status varchar(4) NOT NULL DEFAULT 'SGDN', + valued_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (isolation_status IN ('SGDN')), + CHECK ((quantity_whole IS NOT NULL) OR (quantity_fractional IS NOT NULL)), + CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$') +); +COMMENT ON TABLE portfolios IS 'Остатки ценных бумаг на счетах депо. Whole/Fractional — choice по XSD QuantityType (заполняется ровно одно).'; +COMMENT ON COLUMN portfolios.quantity_whole IS 'Целое количество (для акций, облигаций).'; +COMMENT ON COLUMN portfolios.quantity_fractional IS 'Дробное количество (для паёв ПИФ, до 16 знаков после точки).'; +COMMENT ON COLUMN portfolios.isolation_status IS 'Статус обособления по XSD НРД, всегда SGDN.'; +COMMENT ON COLUMN portfolios.valued_at IS 'Дата/время оценки (на какой момент актуален остаток).'; + +CREATE INDEX IF NOT EXISTS idx_portfolios_client ON portfolios(client_id); +CREATE INDEX IF NOT EXISTS idx_portfolios_depo ON portfolios(depo_account_id); +CREATE INDEX IF NOT EXISTS idx_portfolios_security ON portfolios(security_code); +CREATE INDEX IF NOT EXISTS idx_portfolios_valued_at ON portfolios(valued_at DESC); + +-- --------------------------------------------------------------------- +-- etl_errors — ошибки выгрузки Fansy +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS etl_errors ( + id bigserial PRIMARY KEY, + source_table text NOT NULL, + source_pk text, + payload jsonb, + error_message text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE etl_errors IS 'Журнал ошибок выгрузки Fansy: что не смогли записать в staging и почему.'; +COMMENT ON COLUMN etl_errors.source_table IS 'Название таблицы в источнике (Fansy).'; +COMMENT ON COLUMN etl_errors.source_pk IS 'Первичный ключ записи в источнике (для повторной попытки).'; +COMMENT ON COLUMN etl_errors.payload IS 'Сама запись, которую не удалось загрузить (для диагностики).'; + +CREATE INDEX IF NOT EXISTS idx_etl_errors_created ON etl_errors(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_etl_errors_table ON etl_errors(source_table); diff --git a/docs/fansy-contract/v1/ddl/003__staging.sql b/docs/fansy-contract/v1/ddl/003__staging.sql new file mode 100644 index 0000000..8cacd23 --- /dev/null +++ b/docs/fansy-contract/v1/ddl/003__staging.sql @@ -0,0 +1,109 @@ +-- 003__staging.sql +-- Staging-схема. Структура повторяет fansy.*, плюс loaded_at и +-- допущения на промежуточные NULL'ы (валидация будет в процессе +-- перелива в fansy.*). + +SET search_path TO fansy_staging, public; + +CREATE TABLE IF NOT EXISTS participants ( + inn varchar(10) PRIMARY KEY, + ogrn varchar(15), + full_name_rus text, + short_name_rus text, + display_name_rus text, + full_name_eng text, + short_name_eng text, + display_name_eng text, + depository_participant_code varchar(12), + broker_participant_code varchar(12), + is_available_for_m2m boolean, + comment text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE participants IS 'Staging для справочника участников. Перезаливка целиком, не чаще раза в сутки.'; + +CREATE TABLE IF NOT EXISTS securities ( + security_code char(12) PRIMARY KEY, + isin char(12), + classification varchar(4), + category varchar(4), + security_type varchar(256), + security_series text, + reg_number varchar(256), + fund_class varchar(120), + display_name text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE securities IS 'Staging для справочника ЦБ. Перезаливка целиком.'; + +CREATE TABLE IF NOT EXISTS clients ( + id uuid PRIMARY KEY, + inn varchar(12), + last_name varchar(50), + first_name varchar(50), + middle_name varchar(50), + birth_date date, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE clients IS 'Staging для клиентов. Инкрементный UPSERT по id.'; + +CREATE TABLE IF NOT EXISTS client_documents ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + document_type varchar(2), + series text, + number text, + issued_at date, + issuer text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE client_documents IS 'Staging для документов клиента. UPSERT по id.'; + +CREATE TABLE IF NOT EXISTS iia_contracts ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + agreement_type varchar(3), + agreement_number varchar(128), + agreement_date date, + broker_inn varchar(10), + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE iia_contracts IS 'Staging для договоров ИИС.'; + +CREATE TABLE IF NOT EXISTS settlement_requisites ( + id uuid PRIMARY KEY, + inn varchar(10) NOT NULL, + display_name text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE settlement_requisites IS 'Staging для реквизитов расчётов.'; + +CREATE TABLE IF NOT EXISTS depo_accounts ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + deponent_code varchar(50), + account_id varchar(50), + section_id varchar(50), + depository_inn varchar(10), + is_active boolean, + is_trading boolean, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE depo_accounts IS 'Staging для депо-счетов.'; + +CREATE TABLE IF NOT EXISTS portfolios ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + depo_account_id uuid NOT NULL, + security_code char(12) NOT NULL, + isin char(12), + quantity_whole numeric(38, 0), + quantity_fractional numeric(38, 16), + isolation_status varchar(4), + valued_at timestamptz, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE portfolios IS 'Staging для портфелей. UPSERT по id; SLA свежести — 1 мин.'; + +CREATE INDEX IF NOT EXISTS idx_stg_portfolios_loaded ON portfolios(loaded_at DESC); +CREATE INDEX IF NOT EXISTS idx_stg_clients_loaded ON clients(loaded_at DESC); diff --git a/docs/fansy-contract/v1/ddl/004__seed_participants.sql b/docs/fansy-contract/v1/ddl/004__seed_participants.sql new file mode 100644 index 0000000..4b5c35d --- /dev/null +++ b/docs/fansy-contract/v1/ddl/004__seed_participants.sql @@ -0,0 +1,56 @@ +-- 004__seed_participants.sql +-- Предзаполнение справочника участников по DOC/Справочник пользователей.pdf +-- НРД и тестовые контрагенты Регламента M2M. + +SET search_path TO fansy, public; + +INSERT INTO participants ( + inn, ogrn, full_name_rus, short_name_rus, display_name_rus, + full_name_eng, short_name_eng, display_name_eng, + depository_participant_code, broker_participant_code, + is_available_for_m2m, comment +) VALUES + ( + '7702165310', '1027739132563', + 'Небанковская кредитная организация акционерное общество "Национальный расчётный депозитарий"', + 'НКО АО НРД', 'НРД', + 'National Settlement Depository', 'NSD', 'NSD', + 'MC0010300000', NULL, true, + 'Центральный депозитарий, держатель реестра M2M-сделок.' + ), + ( + '5406121446', '1025402459334', + 'Общество с ограниченной ответственностью "Компания БКС"', + 'ООО "Компания БКС"', 'БКС', + 'BCS Company Ltd', 'BCS', 'BCS', + NULL, 'MC0079200001', true, + 'Брокер БКС, контрагент M2M.' + ), + ( + '7709258228', '1027739675260', + 'Общество с ограниченной ответственностью "Ренессанс Брокер"', + 'ООО "Ренессанс Брокер"', 'Ренессанс Брокер', + 'Renaissance Broker Ltd', 'Renaissance', 'Renaissance', + NULL, 'MC0010300032', true, + 'Брокер Ренессанс, контрагент M2M.' + ), + ( + '7728168971', '1027700067328', + 'Акционерное общество "Альфа-Банк"', + 'АО "Альфа-Банк"', 'Альфа-Банк', + 'Alfa-Bank JSC', 'Alfa-Bank', 'Alfa-Bank', + NULL, 'MC0079200033', true, + 'Брокер Альфа-Банк, контрагент M2M.' + ) +ON CONFLICT (inn) DO UPDATE SET + full_name_rus = EXCLUDED.full_name_rus, + short_name_rus = EXCLUDED.short_name_rus, + display_name_rus = EXCLUDED.display_name_rus, + full_name_eng = EXCLUDED.full_name_eng, + short_name_eng = EXCLUDED.short_name_eng, + display_name_eng = EXCLUDED.display_name_eng, + depository_participant_code = EXCLUDED.depository_participant_code, + broker_participant_code = EXCLUDED.broker_participant_code, + is_available_for_m2m = EXCLUDED.is_available_for_m2m, + comment = EXCLUDED.comment, + updated_at = now(); diff --git a/docs/fansy-contract/v1/etl-requirements.md b/docs/fansy-contract/v1/etl-requirements.md new file mode 100644 index 0000000..5a98410 --- /dev/null +++ b/docs/fansy-contract/v1/etl-requirements.md @@ -0,0 +1,87 @@ +# Требования к ETL Fansy → fansy-store v1 + +## Подключение + +- СУБД: PostgreSQL 16 / PostgreSQL Pro Certified (по согласованию). +- Хост, порт, имя БД, IP-allowlist — выдаются администратором ВМ + Bridge-and-Join-s отдельно. +- Учётная запись: роль **`fansy_etl`** (создаётся миграцией + `000__roles.sql`). Пароль выдаётся через защищённый канал, не в + репозиторий. +- TLS: обязательно (`sslmode=verify-full` со стороны клиента ETL). + +## Куда писать + +- Только в схему `fansy_staging`. Прав на DDL нет, на схему `fansy` + тоже нет. INSERT/UPDATE/SELECT на таблицы staging. +- Запись в `fansy.*` происходит на нашей стороне после валидации. + +## Тип load + +- **Инкрементный UPSERT** в staging по PK (`id`): + ```sql + INSERT INTO fansy_staging.clients (id, ...) VALUES (...) + ON CONFLICT (id) DO UPDATE SET ..., loaded_at = now(); + ``` +- Справочники с относительно небольшим размером и редкой сменой + (`securities`, `participants`) разрешена **полная перезаливка** не + чаще одного раза в сутки. Полная перезаливка реализуется через + транзакцию: `TRUNCATE` + `COPY` + `COMMIT`. + +## SLA на свежесть данных + +| Таблица | SLA свежести | +|---|---| +| `portfolios` | ≤ 1 минута после фактического изменения в Fansy | +| `clients`, `depo_accounts`, `client_documents`, `iia_contracts` | ≤ 5 минут | +| `securities`, `participants`, `settlement_requisites` | ≤ 24 часа (по событию или по расписанию) | + +## Форматы и кодировки + +- Все timestamp — `timestamptz` в **UTC** (явная зона `+00`). +- Все строковые поля — UTF-8. +- ИНН, коды депонентов, ISIN, SecurityCode — в верхнем регистре. +- Числа с дробной частью (`numeric(38,16)`) — точка как разделитель, + без разделителей тысяч. + +## Обработка ошибок + +При нарушении CHECK-ограничений, FK или типов команда Fansy: + +1. Пишет запись в `fansy_staging.etl_errors`: + ```sql + INSERT INTO fansy_staging.etl_errors (source_table, source_pk, payload, error_message) + VALUES ('fansy.position', '', '', ''); + ``` +2. Логирует у себя и продолжает работу. +3. Не блокирует загрузку остальных записей. + +Мы (Bridge-and-Join-s) еженедельно просматриваем `etl_errors`, +поднимаем инциденты с командой Fansy. + +## Окна и расписание + +- Регламентное окно простоя — **с 23:00 до 23:30 МСК**, по средам. + В это время ETL может приостанавливаться для обновлений. +- Внеплановые работы — анонсируются за 2 часа в общем чате. + +## Конфиденциальность + +- ПДн (ФИО, документ, дата рождения) — только по нужным таблицам. +- Журналирование SQL-запросов ETL **не должно** включать значения ПДн. +- Соединения только с IP-allowlist'а. + +## Контроль и наблюдаемость + +Мы предоставим команде Fansy `read-only` доступ к двум представлениям: + +- `fansy_staging.v_load_lag` — задержка свежести по таблицам. +- `fansy_staging.v_load_stats` — счётчики INSERT/UPDATE за сутки. + +(Создаются в более позднем PR — `M3`.) + +## Точка контакта + +- Технический контакт со стороны Bridge-and-Join-s — указан в + `docs/architecture/plan.md`, раздел «Контакты». +- Эскалация — в общий канал интеграции, тред «fansy-store ETL». diff --git a/docs/fansy-contract/v1/examples/example-claim.md b/docs/fansy-contract/v1/examples/example-claim.md new file mode 100644 index 0000000..9022205 --- /dev/null +++ b/docs/fansy-contract/v1/examples/example-claim.md @@ -0,0 +1,118 @@ +# Пример заявки M2M end-to-end + +Типовой сценарий: инвестор Иванов И.И. подаёт через ЛК заявку на +перевод 3 ценных бумаг с депо-счёта у БКС в депо-счёт у Ренессанс +Брокера. Один из переводов — паи ПИФ с дробным количеством. ИИС +тип T03. + +## Какие данные нужны m2m-core для формирования M2MTransferRequest + +Сервис `m2m-core` достаёт следующее из `fansy-store` (рабочая схема +`fansy`) по идентификатору клиента и набору ЦБ: + +### 1. Анкета клиента (для `InvestorInformation`) + +```sql +SELECT + c.last_name, + c.first_name, + c.middle_name, + d.document_type, + d.series AS document_series, + d.number AS document_number +FROM fansy.clients c +JOIN fansy.client_documents d ON d.client_id = c.id +WHERE c.id = :client_id +ORDER BY d.created_at DESC +LIMIT 1; +``` + +### 2. ИИС-договор (для `IIAAgreementDetails`) + +```sql +SELECT agreement_type, agreement_number, agreement_date, broker_inn +FROM fansy.iia_contracts +WHERE client_id = :client_id +ORDER BY agreement_date DESC +LIMIT 1; +``` + +### 3. Реквизиты передающего/принимающего депозитариев + +```sql +SELECT inn +FROM fansy.settlement_requisites +WHERE inn IN (:transferring_inn, :receiving_inn); +``` + +### 4. Депо-счета и разделы инвестора (для `SettlementAccount`) + +```sql +SELECT + da.deponent_code, + da.account_id, + da.section_id, + da.depository_inn +FROM fansy.depo_accounts da +WHERE da.client_id = :client_id + AND da.depository_inn = :depository_inn + AND da.is_active = true; +``` + +### 5. Информация о ценных бумагах и их остатках + +```sql +SELECT + p.security_code, + s.isin, + s.classification, + s.category, + s.security_type, + s.reg_number, + s.fund_class, + p.quantity_whole, + p.quantity_fractional, + p.isolation_status +FROM fansy.portfolios p +JOIN fansy.securities s USING (security_code) +WHERE p.client_id = :client_id + AND p.security_code = ANY(:requested_codes) + AND p.valued_at >= now() - interval '5 minutes'; +``` + +### 6. Проверка достаточности остатков + +```sql +SELECT + p.security_code, + COALESCE(p.quantity_whole, 0) + COALESCE(p.quantity_fractional, 0) AS available +FROM fansy.portfolios p +WHERE p.client_id = :client_id + AND p.security_code = ANY(:requested_codes); +``` + +Сравниваем `available` с запрошенным количеством. Если меньше — отказ +от формирования M2MTransferRequest, ошибка в ЛК. + +## Какие данные команда Fansy обязана положить в staging + +Из примера выше: + +- `clients`: запись на инвестора Иванова И.И. +- `client_documents`: документ с DocumentType `21`. +- `iia_contracts`: договор T03 с брокером (БКС, ИНН 5406121446). +- `depo_accounts`: счёт у БКС с разделом для перевода и счёт у + Ренессанс Брокера. +- `securities`: 3 записи (SHAR/ORDN, SHAR/PREF, MFUN/UKWN с + fund_class='A'). +- `portfolios`: остатки по этим 3 ЦБ на 1500 / 300 / 2500.75 + соответственно. +- `participants`: НРД, БКС (5406121446), Ренессанс (7709258228) — из + начального seed. + +## Результат + +`m2m-core` собирает данные → формирует `M2MTransferRequest` → +валидирует → подписывает (через `crypto-service`) → отправляет в НРД +через `nsd-adapter`. Получает `M2MTransferDecision` от принимающей +стороны, обновляет статус сделки и шлёт callback в ЛК. diff --git a/docs/fansy-contract/v1/examples/seed-data.sql b/docs/fansy-contract/v1/examples/seed-data.sql new file mode 100644 index 0000000..6275d39 --- /dev/null +++ b/docs/fansy-contract/v1/examples/seed-data.sql @@ -0,0 +1,90 @@ +-- seed-data.sql +-- Тестовые данные для совместного приёмочного тестирования +-- Bridge-and-Join-s ↔ команда Fansy. Запускать поверх 002__working.sql. + +SET search_path TO fansy, public; + +BEGIN; + +-- --------------------------------------------------------------------- +-- Реквизиты депозитариев +-- --------------------------------------------------------------------- + +INSERT INTO settlement_requisites (id, inn, display_name) VALUES + ('00000000-0000-0000-0000-000000000001', '7702070139', 'Депозитарий Сбербанк'), + ('00000000-0000-0000-0000-000000000002', '7802031669', 'Депозитарий СПб Банк'), + ('00000000-0000-0000-0000-000000000003', '0702345678', 'Депозитарий БКС'), + ('00000000-0000-0000-0000-000000000004', '0710987654', 'Депозитарий Ренессанс') +ON CONFLICT (inn) DO NOTHING; + +-- --------------------------------------------------------------------- +-- Справочник ЦБ (минимальный) +-- --------------------------------------------------------------------- + +INSERT INTO securities (security_code, isin, classification, category, security_type, reg_number, display_name) VALUES + ('MM0766162534', 'RU0007661625', 'SHAR', 'ORDN', 'Акция обыкновенная', '1-01-00077-A', 'Газпром ао'), + ('MM0907654321', 'RU0009029540', 'SHAR', 'PREF', 'Акция привилегированная', '2-02-00009-A', 'Сбербанк ап'), + ('MM2300100100', NULL, 'MFUN', 'UKWN', 'Пай ПИФ', '23-001', 'ПИФ Альфа Капитал') +ON CONFLICT (security_code) DO NOTHING; + +UPDATE securities SET fund_class = 'A' WHERE security_code = 'MM2300100100'; + +-- --------------------------------------------------------------------- +-- 5 тестовых клиентов +-- --------------------------------------------------------------------- + +INSERT INTO clients (id, last_name, first_name, middle_name, birth_date) VALUES + ('11111111-1111-1111-1111-111111111111', 'Иванов', 'Иван', 'Иванович', '1980-01-15'), + ('22222222-2222-2222-2222-222222222222', 'Петров', 'Пётр', 'Петрович', '1985-06-20'), + ('33333333-3333-3333-3333-333333333333', 'Сидоров', 'Сидор', 'Сидорович', '1990-11-30'), + ('44444444-4444-4444-4444-444444444444', 'Кузнецов','Сергей','Михайлович','1975-03-10'), + ('55555555-5555-5555-5555-555555555555', 'Соколова','Анна', 'Викторовна','1988-09-25') +ON CONFLICT (id) DO NOTHING; + +-- --------------------------------------------------------------------- +-- Документы клиентов +-- --------------------------------------------------------------------- + +INSERT INTO client_documents (id, client_id, document_type, series, number, issued_at, issuer) VALUES + ('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '21', '4512', '654321', '2010-05-12', 'ОУФМС России по Москве'), + ('a0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', '21', '4513', '654322', '2011-06-13', 'ОУФМС России по Москве'), + ('a0000000-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', '21', '4514', '654323', '2012-07-14', 'ОУФМС России по СПб'), + ('a0000000-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', '03', '111', '222333', '1995-08-15', 'Свидетельство о рождении'), + ('a0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', '21', '4516', '654325', '2014-09-16', 'ОУФМС России по СПб') +ON CONFLICT (id) DO NOTHING; + +-- --------------------------------------------------------------------- +-- ИИС-договоры (для 3 клиентов) +-- --------------------------------------------------------------------- + +INSERT INTO iia_contracts (id, client_id, agreement_type, agreement_number, agreement_date, broker_inn) VALUES + ('b0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'T03', 'ИИС78/2024', '2026-01-15', '5406121446'), + ('b0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'T12', 'ИИС79/2023', '2025-12-01', '7709258228'), + ('b0000000-0000-0000-0000-000000000003', '55555555-5555-5555-5555-555555555555', 'T03', 'ИИС80/2024', '2026-02-10', '7728168971') +ON CONFLICT (id) DO NOTHING; + +-- --------------------------------------------------------------------- +-- Депо-счета +-- --------------------------------------------------------------------- + +INSERT INTO depo_accounts (id, client_id, deponent_code, account_id, section_id, depository_inn, is_active, is_trading) VALUES + ('c0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'DP789456', '31MC0021900000F01', 'P001', '7702070139', true, true), + ('c0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'AA789451', '33MC0021900000F02', 'F002', '7802031669', true, true), + ('c0000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'DP100200', '31MC0010000000A01', 'A001', '7702070139', true, true), + ('c0000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'DP300400', '31MC0030000000B01', 'B001', '0702345678', true, true), + ('c0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'DP500600', '31MC0050000000C01', 'C001', '0710987654', true, true) +ON CONFLICT (deponent_code, account_id, section_id) DO NOTHING; + +-- --------------------------------------------------------------------- +-- Портфели (остатки ЦБ) +-- --------------------------------------------------------------------- + +INSERT INTO portfolios (id, client_id, depo_account_id, security_code, isin, quantity_whole, quantity_fractional, valued_at) VALUES + ('d0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0766162534', 'RU0007661625', 1500, NULL, now()), + ('d0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0907654321', 'RU0009029540', 300, NULL, now()), + ('d0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM2300100100', NULL, NULL, 2500.75, now()), + ('d0000000-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-000000000003', 'MM0766162534', 'RU0007661625', 5000, NULL, now()), + ('d0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'c0000000-0000-0000-0000-000000000005', 'MM2300100100', NULL, NULL, 100.00, now()) +ON CONFLICT (id) DO NOTHING; + +COMMIT; diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 5ce4307..a72039b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -12,7 +12,7 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос | PR | Файл | Статус | Зависит от | |----|------|--------|-----------| | PR-1 | `PR-1-go-models-m2m.md` | выполнено | — | -| PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) | +| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) | | PR-3 | `PR-3-lk-openapi.md` | готово к запуску | — (параллельно с PR-1) | | PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 | | PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 | diff --git a/migrations/fansy-store/000__roles.sql b/migrations/fansy-store/000__roles.sql new file mode 100644 index 0000000..9642883 --- /dev/null +++ b/migrations/fansy-store/000__roles.sql @@ -0,0 +1,26 @@ +-- 000__roles.sql +-- Роли для принимающей БД fansy-store. +-- Запускать первым, отдельно от структурных миграций. +-- Пароли проставляются администратором БД через ALTER ROLE. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fansy_etl') THEN + CREATE ROLE fansy_etl LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE fansy_etl IS + 'Роль команды Fansy для ETL: INSERT/UPDATE/SELECT в схему fansy_staging. DDL-прав нет.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_reader') THEN + CREATE ROLE bj_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE bj_reader IS + 'Роль сервисов Bridge-and-Join-s (m2m-core, lk-gateway) для чтения схемы fansy.'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_migrator') THEN + CREATE ROLE bj_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT; + COMMENT ON ROLE bj_migrator IS + 'Роль с DDL-правами для миграций. Только эта роль может CREATE/ALTER/DROP.'; + END IF; +END +$$; diff --git a/migrations/fansy-store/001__schemas.sql b/migrations/fansy-store/001__schemas.sql new file mode 100644 index 0000000..7ed23b8 --- /dev/null +++ b/migrations/fansy-store/001__schemas.sql @@ -0,0 +1,23 @@ +-- 001__schemas.sql +-- Две схемы: fansy_staging (куда пишет ETL Fansy) и fansy (рабочая, +-- куда переливаются данные после валидации). + +CREATE SCHEMA IF NOT EXISTS fansy_staging AUTHORIZATION bj_migrator; +COMMENT ON SCHEMA fansy_staging IS + 'Staging-схема. ETL Fansy делает UPSERT в эти таблицы. Сюда же пишутся ошибки выгрузки.'; + +CREATE SCHEMA IF NOT EXISTS fansy AUTHORIZATION bj_migrator; +COMMENT ON SCHEMA fansy IS + 'Рабочая схема. Сюда переливаются актуальные данные триггерами или процедурами после валидации staging.'; + +-- Права по ролям. DDL-права остаются только у владельца bj_migrator. +GRANT USAGE ON SCHEMA fansy_staging TO fansy_etl; +GRANT USAGE ON SCHEMA fansy TO bj_reader; + +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging + GRANT SELECT, INSERT, UPDATE ON TABLES TO fansy_etl; +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO fansy_etl; + +ALTER DEFAULT PRIVILEGES IN SCHEMA fansy + GRANT SELECT ON TABLES TO bj_reader; diff --git a/migrations/fansy-store/002__working.sql b/migrations/fansy-store/002__working.sql new file mode 100644 index 0000000..77478fa --- /dev/null +++ b/migrations/fansy-store/002__working.sql @@ -0,0 +1,231 @@ +-- 002__working.sql +-- Рабочая схема fansy. Данные сюда переливаются из fansy_staging после +-- валидации. Сервисы Bridge-and-Join-s читают только эту схему. + +SET search_path TO fansy, public; + +-- --------------------------------------------------------------------- +-- participants — справочник участников сервиса MOST (контрагенты M2M) +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS participants ( + inn varchar(10) PRIMARY KEY, + ogrn varchar(15), + full_name_rus text NOT NULL, + short_name_rus text, + display_name_rus text NOT NULL, + full_name_eng text, + short_name_eng text, + display_name_eng text, + depository_participant_code varchar(12), + broker_participant_code varchar(12), + is_available_for_m2m boolean NOT NULL DEFAULT false, + comment text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn ~ '^[0-9]{10}$'), + CHECK (depository_participant_code IS NULL OR depository_participant_code ~ '^[A-Z0-9]+$'), + CHECK (broker_participant_code IS NULL OR broker_participant_code ~ '^[A-Z0-9]+$') +); +COMMENT ON TABLE participants IS 'Справочник участников сервиса MOST: депозитарии и брокеры, между которыми идут M2M-переводы.'; +COMMENT ON COLUMN participants.inn IS 'ИНН юрлица (10 цифр), первичный ключ.'; +COMMENT ON COLUMN participants.depository_participant_code IS 'Код участника M2M на стороне депозитария (для DepositoryPlace в M2MTransferHandbook).'; +COMMENT ON COLUMN participants.broker_participant_code IS 'Код участника M2M на стороне брокера (для BrokerPlace).'; +COMMENT ON COLUMN participants.is_available_for_m2m IS 'Готовность участника принимать/отправлять M2M-сообщения (включается после подписания НРД-договора).'; + +CREATE INDEX IF NOT EXISTS idx_participants_dep_code ON participants(depository_participant_code) WHERE depository_participant_code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_participants_brk_code ON participants(broker_participant_code) WHERE broker_participant_code IS NOT NULL; + +-- --------------------------------------------------------------------- +-- securities — справочник ценных бумаг +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS securities ( + security_code char(12) PRIMARY KEY, + isin char(12), + classification varchar(4), + category varchar(4), + security_type varchar(256), + security_series text, + reg_number varchar(256), + fund_class varchar(120), + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (security_code ~ '^[0-9A-Z_/-]+$'), + CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'), + CHECK (classification IS NULL OR classification IN ('BOND', 'SHAR', 'MFUN')), + CHECK (category IS NULL OR category IN ('ORDN', 'PREF', 'UKWN')) +); +COMMENT ON TABLE securities IS 'Справочник ценных бумаг с их идентификаторами и классификацией.'; +COMMENT ON COLUMN securities.security_code IS 'Идентификатор ценной бумаги в системе НРД (XSD SecurityCodeType).'; +COMMENT ON COLUMN securities.classification IS 'Тип ценной бумаги: BOND (облигация), SHAR (акция), MFUN (ПИФ).'; +COMMENT ON COLUMN securities.category IS 'Категория акций: ORDN (обыкновенные), PREF (привилегированные), UKWN (неизвестно).'; +COMMENT ON COLUMN securities.reg_number IS 'Регистрационный номер выпуска (для акций и облигаций) или регномер правил доверительного управления ПИФ.'; +COMMENT ON COLUMN securities.fund_class IS 'Класс паёв ПИФа (если применимо).'; + +CREATE INDEX IF NOT EXISTS idx_securities_isin ON securities(isin) WHERE isin IS NOT NULL; + +-- --------------------------------------------------------------------- +-- clients — депоненты / инвесторы +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS clients ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inn varchar(12), + last_name varchar(50) NOT NULL, + first_name varchar(50) NOT NULL, + middle_name varchar(50), + birth_date date, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn IS NULL OR inn ~ '^[0-9]{10,12}$') +); +COMMENT ON TABLE clients IS 'Депоненты-физлица. Привязка к документам и счетам — через FK из дочерних таблиц.'; +COMMENT ON COLUMN clients.inn IS 'ИНН физлица (12 цифр) или организации (10 цифр), опционально.'; +COMMENT ON COLUMN clients.last_name IS 'Фамилия (XSD String50, обязательно).'; +COMMENT ON COLUMN clients.first_name IS 'Имя (XSD String50, обязательно).'; +COMMENT ON COLUMN clients.middle_name IS 'Отчество (XSD String50, опционально).'; + +CREATE INDEX IF NOT EXISTS idx_clients_inn ON clients(inn) WHERE inn IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_clients_lastname ON clients(last_name, first_name); + +-- --------------------------------------------------------------------- +-- client_documents — документы инвестора +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS client_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + document_type varchar(2) NOT NULL, + series text, + number text NOT NULL, + issued_at date, + issuer text, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (document_type IN ( + '01','02','03','04','05','06','07','09','10','11','12','13','14', + '21','22','23','26','27','91' + )), + CHECK (series IS NULL OR series ~ '^\S+$'), + CHECK (number ~ '^\S+$') +); +COMMENT ON TABLE client_documents IS 'Документы, удостоверяющие личность инвестора. Коды по справочнику НРД (XSD IdentityDocumentCodeEnum).'; +COMMENT ON COLUMN client_documents.document_type IS 'Код вида документа (01..91, см. XSD НРД).'; +COMMENT ON COLUMN client_documents.series IS 'Серия документа (без пробелов).'; +COMMENT ON COLUMN client_documents.number IS 'Номер документа (без пробелов, обязательно).'; + +CREATE INDEX IF NOT EXISTS idx_client_documents_client ON client_documents(client_id); + +-- --------------------------------------------------------------------- +-- iia_contracts — договоры ИИС +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS iia_contracts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + agreement_type varchar(3) NOT NULL, + agreement_number varchar(128) NOT NULL, + agreement_date date NOT NULL, + broker_inn varchar(10) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (agreement_type IN ('T12', 'T03')), + CHECK (broker_inn ~ '^[0-9]{10}$') +); +COMMENT ON TABLE iia_contracts IS 'Договоры на ведение ИИС инвестора.'; +COMMENT ON COLUMN iia_contracts.agreement_type IS 'Тип договора: T12 — ИИС-1/ИИС-2 (старый формат); T03 — ИИС-3 (новый).'; +COMMENT ON COLUMN iia_contracts.broker_inn IS 'ИНН брокера, с которым заключён договор ИИС.'; + +CREATE INDEX IF NOT EXISTS idx_iia_contracts_client ON iia_contracts(client_id); +CREATE INDEX IF NOT EXISTS idx_iia_contracts_broker ON iia_contracts(broker_inn); + +-- --------------------------------------------------------------------- +-- settlement_requisites — реквизиты расчётов (депозитарии) +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS settlement_requisites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inn varchar(10) NOT NULL UNIQUE, + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (inn ~ '^[0-9]{10}$') +); +COMMENT ON TABLE settlement_requisites IS 'Реквизиты передающего и принимающего депозитариев (XSD SettlementRequisitesType — содержит только ИНН).'; + +-- --------------------------------------------------------------------- +-- depo_accounts — депо-счета и разделы +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS depo_accounts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE RESTRICT, + deponent_code varchar(50) NOT NULL, + account_id varchar(50) NOT NULL, + section_id varchar(50) NOT NULL, + depository_inn varchar(10) NOT NULL, + is_active boolean NOT NULL DEFAULT true, + is_trading boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (depository_inn ~ '^[0-9]{10}$'), + UNIQUE (deponent_code, account_id, section_id) +); +COMMENT ON TABLE depo_accounts IS 'Счета депо инвестора и их разделы у различных депозитариев.'; +COMMENT ON COLUMN depo_accounts.deponent_code IS 'Код депонента у конкретного депозитария (XSD SettlementDepositoryLocationType.DeponentCode).'; +COMMENT ON COLUMN depo_accounts.account_id IS 'Номер счёта депо (XSD AccountIdType).'; +COMMENT ON COLUMN depo_accounts.section_id IS 'Номер раздела счёта депо.'; +COMMENT ON COLUMN depo_accounts.is_trading IS 'Признак торгового раздела (для отделения от изолированных).'; + +CREATE INDEX IF NOT EXISTS idx_depo_accounts_client ON depo_accounts(client_id); +CREATE INDEX IF NOT EXISTS idx_depo_accounts_deponent ON depo_accounts(deponent_code); + +-- --------------------------------------------------------------------- +-- portfolios — портфели и остатки ЦБ +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS portfolios ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + depo_account_id uuid NOT NULL REFERENCES depo_accounts(id) ON DELETE CASCADE, + security_code char(12) NOT NULL REFERENCES securities(security_code), + isin char(12), + quantity_whole numeric(38, 0), + quantity_fractional numeric(38, 16), + isolation_status varchar(4) NOT NULL DEFAULT 'SGDN', + valued_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (isolation_status IN ('SGDN')), + CHECK ((quantity_whole IS NOT NULL) OR (quantity_fractional IS NOT NULL)), + CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$') +); +COMMENT ON TABLE portfolios IS 'Остатки ценных бумаг на счетах депо. Whole/Fractional — choice по XSD QuantityType (заполняется ровно одно).'; +COMMENT ON COLUMN portfolios.quantity_whole IS 'Целое количество (для акций, облигаций).'; +COMMENT ON COLUMN portfolios.quantity_fractional IS 'Дробное количество (для паёв ПИФ, до 16 знаков после точки).'; +COMMENT ON COLUMN portfolios.isolation_status IS 'Статус обособления по XSD НРД, всегда SGDN.'; +COMMENT ON COLUMN portfolios.valued_at IS 'Дата/время оценки (на какой момент актуален остаток).'; + +CREATE INDEX IF NOT EXISTS idx_portfolios_client ON portfolios(client_id); +CREATE INDEX IF NOT EXISTS idx_portfolios_depo ON portfolios(depo_account_id); +CREATE INDEX IF NOT EXISTS idx_portfolios_security ON portfolios(security_code); +CREATE INDEX IF NOT EXISTS idx_portfolios_valued_at ON portfolios(valued_at DESC); + +-- --------------------------------------------------------------------- +-- etl_errors — ошибки выгрузки Fansy +-- --------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS etl_errors ( + id bigserial PRIMARY KEY, + source_table text NOT NULL, + source_pk text, + payload jsonb, + error_message text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE etl_errors IS 'Журнал ошибок выгрузки Fansy: что не смогли записать в staging и почему.'; +COMMENT ON COLUMN etl_errors.source_table IS 'Название таблицы в источнике (Fansy).'; +COMMENT ON COLUMN etl_errors.source_pk IS 'Первичный ключ записи в источнике (для повторной попытки).'; +COMMENT ON COLUMN etl_errors.payload IS 'Сама запись, которую не удалось загрузить (для диагностики).'; + +CREATE INDEX IF NOT EXISTS idx_etl_errors_created ON etl_errors(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_etl_errors_table ON etl_errors(source_table); diff --git a/migrations/fansy-store/003__staging.sql b/migrations/fansy-store/003__staging.sql new file mode 100644 index 0000000..8cacd23 --- /dev/null +++ b/migrations/fansy-store/003__staging.sql @@ -0,0 +1,109 @@ +-- 003__staging.sql +-- Staging-схема. Структура повторяет fansy.*, плюс loaded_at и +-- допущения на промежуточные NULL'ы (валидация будет в процессе +-- перелива в fansy.*). + +SET search_path TO fansy_staging, public; + +CREATE TABLE IF NOT EXISTS participants ( + inn varchar(10) PRIMARY KEY, + ogrn varchar(15), + full_name_rus text, + short_name_rus text, + display_name_rus text, + full_name_eng text, + short_name_eng text, + display_name_eng text, + depository_participant_code varchar(12), + broker_participant_code varchar(12), + is_available_for_m2m boolean, + comment text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE participants IS 'Staging для справочника участников. Перезаливка целиком, не чаще раза в сутки.'; + +CREATE TABLE IF NOT EXISTS securities ( + security_code char(12) PRIMARY KEY, + isin char(12), + classification varchar(4), + category varchar(4), + security_type varchar(256), + security_series text, + reg_number varchar(256), + fund_class varchar(120), + display_name text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE securities IS 'Staging для справочника ЦБ. Перезаливка целиком.'; + +CREATE TABLE IF NOT EXISTS clients ( + id uuid PRIMARY KEY, + inn varchar(12), + last_name varchar(50), + first_name varchar(50), + middle_name varchar(50), + birth_date date, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE clients IS 'Staging для клиентов. Инкрементный UPSERT по id.'; + +CREATE TABLE IF NOT EXISTS client_documents ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + document_type varchar(2), + series text, + number text, + issued_at date, + issuer text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE client_documents IS 'Staging для документов клиента. UPSERT по id.'; + +CREATE TABLE IF NOT EXISTS iia_contracts ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + agreement_type varchar(3), + agreement_number varchar(128), + agreement_date date, + broker_inn varchar(10), + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE iia_contracts IS 'Staging для договоров ИИС.'; + +CREATE TABLE IF NOT EXISTS settlement_requisites ( + id uuid PRIMARY KEY, + inn varchar(10) NOT NULL, + display_name text, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE settlement_requisites IS 'Staging для реквизитов расчётов.'; + +CREATE TABLE IF NOT EXISTS depo_accounts ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + deponent_code varchar(50), + account_id varchar(50), + section_id varchar(50), + depository_inn varchar(10), + is_active boolean, + is_trading boolean, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE depo_accounts IS 'Staging для депо-счетов.'; + +CREATE TABLE IF NOT EXISTS portfolios ( + id uuid PRIMARY KEY, + client_id uuid NOT NULL, + depo_account_id uuid NOT NULL, + security_code char(12) NOT NULL, + isin char(12), + quantity_whole numeric(38, 0), + quantity_fractional numeric(38, 16), + isolation_status varchar(4), + valued_at timestamptz, + loaded_at timestamptz NOT NULL DEFAULT now() +); +COMMENT ON TABLE portfolios IS 'Staging для портфелей. UPSERT по id; SLA свежести — 1 мин.'; + +CREATE INDEX IF NOT EXISTS idx_stg_portfolios_loaded ON portfolios(loaded_at DESC); +CREATE INDEX IF NOT EXISTS idx_stg_clients_loaded ON clients(loaded_at DESC); diff --git a/migrations/fansy-store/004__seed_participants.sql b/migrations/fansy-store/004__seed_participants.sql new file mode 100644 index 0000000..4b5c35d --- /dev/null +++ b/migrations/fansy-store/004__seed_participants.sql @@ -0,0 +1,56 @@ +-- 004__seed_participants.sql +-- Предзаполнение справочника участников по DOC/Справочник пользователей.pdf +-- НРД и тестовые контрагенты Регламента M2M. + +SET search_path TO fansy, public; + +INSERT INTO participants ( + inn, ogrn, full_name_rus, short_name_rus, display_name_rus, + full_name_eng, short_name_eng, display_name_eng, + depository_participant_code, broker_participant_code, + is_available_for_m2m, comment +) VALUES + ( + '7702165310', '1027739132563', + 'Небанковская кредитная организация акционерное общество "Национальный расчётный депозитарий"', + 'НКО АО НРД', 'НРД', + 'National Settlement Depository', 'NSD', 'NSD', + 'MC0010300000', NULL, true, + 'Центральный депозитарий, держатель реестра M2M-сделок.' + ), + ( + '5406121446', '1025402459334', + 'Общество с ограниченной ответственностью "Компания БКС"', + 'ООО "Компания БКС"', 'БКС', + 'BCS Company Ltd', 'BCS', 'BCS', + NULL, 'MC0079200001', true, + 'Брокер БКС, контрагент M2M.' + ), + ( + '7709258228', '1027739675260', + 'Общество с ограниченной ответственностью "Ренессанс Брокер"', + 'ООО "Ренессанс Брокер"', 'Ренессанс Брокер', + 'Renaissance Broker Ltd', 'Renaissance', 'Renaissance', + NULL, 'MC0010300032', true, + 'Брокер Ренессанс, контрагент M2M.' + ), + ( + '7728168971', '1027700067328', + 'Акционерное общество "Альфа-Банк"', + 'АО "Альфа-Банк"', 'Альфа-Банк', + 'Alfa-Bank JSC', 'Alfa-Bank', 'Alfa-Bank', + NULL, 'MC0079200033', true, + 'Брокер Альфа-Банк, контрагент M2M.' + ) +ON CONFLICT (inn) DO UPDATE SET + full_name_rus = EXCLUDED.full_name_rus, + short_name_rus = EXCLUDED.short_name_rus, + display_name_rus = EXCLUDED.display_name_rus, + full_name_eng = EXCLUDED.full_name_eng, + short_name_eng = EXCLUDED.short_name_eng, + display_name_eng = EXCLUDED.display_name_eng, + depository_participant_code = EXCLUDED.depository_participant_code, + broker_participant_code = EXCLUDED.broker_participant_code, + is_available_for_m2m = EXCLUDED.is_available_for_m2m, + comment = EXCLUDED.comment, + updated_at = now();