feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР

- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
@@ -0,0 +1,250 @@
-- Миграция: схема npr с историей НПР1/НПР2/УДС через триггер на "Portfolio".
--
-- Применять ПОСЛЕ того, как таблицы QUIK-экспортёра развёрнуты штатным скриптом
-- quikexport_11/scripts/postgre/exporter_pg.sql
-- в ту же БД. В эталонной "Portfolio" уже есть все нужные колонки
-- (PortfolioValue, InitMargin, MinMargin, CorrectedMargin, FundsLevel, Status, RiskLevel),
-- поэтому отдельного ALTER TABLE не требуется.
--
-- Политика записи: триггер пишет в npr.history только когда
-- (а) клиент появился впервые, или
-- (б) сменилась «зона» УДС (CALL → WARN → OK → INF), или
-- (в) прошло более 30 мин с последней записи по этому клиенту (heartbeat).
-- Это резко сокращает объём истории — фиксируются моменты смены состояния,
-- а не каждый тик экспортёра. Дополняется политикой хранения (retention).
BEGIN;
-- (б) Схема для истории НПР/УДС.
CREATE SCHEMA IF NOT EXISTS npr;
-- (в) Таблица истории. Append-only: свой BIGSERIAL PK, без UNIQUE по клиенту.
CREATE TABLE IF NOT EXISTS npr.history (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(4) NOT NULL,
margin_type varchar(4) NULL,
portfolio_value decimal(28, 8) NULL,
init_margin float NULL,
min_margin float NULL,
corrected_margin float NULL,
rcv1 float NULL,
rcv2 float NULL,
funds_level decimal(19, 2) NULL,
status int NULL,
risk_level int NULL
);
-- (г) Индекс для выборок «история по клиенту за период».
CREATE INDEX IF NOT EXISTS ix_npr_hist_client_ts
ON npr.history (client_code, ts DESC);
-- «Пульс» от экспортёра: триггер обновляет last_tick на каждом тике (не чаще раза в 10 сек).
-- Нужен отдельно от истории, чтобы UI видел «данные от QUIK приходят» даже когда зоны не меняются.
CREATE TABLE IF NOT EXISTS npr.heartbeat (
id int PRIMARY KEY DEFAULT 1,
last_tick timestamptz NOT NULL DEFAULT NOW(),
tick_count bigint NOT NULL DEFAULT 0
);
INSERT INTO npr.heartbeat (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Кэш «последнее записанное состояние» — нужен триггеру, чтобы понять,
-- изменилась ли зона относительно ранее сохранённой строки. Одна запись на клиентский PK.
CREATE TABLE IF NOT EXISTS npr.last_seen (
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(12) NOT NULL,
tag varchar(4) NOT NULL,
last_ts timestamptz NOT NULL,
last_zone text NOT NULL,
last_uds numeric(19,2),
PRIMARY KEY (firm_id, client_code, limit_kind, curr_code, tag)
);
-- Зона УДС: CALL / WARN / OK / INF / UNKNOWN. Используется и триггером, и отчётами.
CREATE OR REPLACE FUNCTION npr._zone(fl numeric) RETURNS text AS $$
SELECT CASE
WHEN fl IS NULL THEN 'UNKNOWN'
WHEN fl >= 9.99 THEN 'INF'
WHEN fl <= -9.99 THEN 'CALL'
WHEN fl < 0 THEN 'CALL'
WHEN fl < 1 THEN 'CALL'
WHEN fl < 1.5 THEN 'WARN'
ELSE 'OK'
END;
$$ LANGUAGE sql IMMUTABLE;
-- (д) Триггер-функция: новая запись в history только при смене зоны или раз в 30 мин.
-- Фильтр МД / МД+ / С применяется сразу, чтобы не тратить ресурсы на остальных.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_to_history()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
DECLARE
new_zone text;
last_ts timestamptz;
last_zone text;
should_write boolean := false;
BEGIN
-- Пульс: обновляем не чаще раза в 10 сек, чтобы не делать тысяч UPDATE на один тик экспортёра.
UPDATE npr.heartbeat
SET last_tick = NOW(), tick_count = tick_count + 1
WHERE id = 1 AND last_tick < NOW() - INTERVAL '10 seconds';
IF NEW."MarginType" NOT IN ('МД','МД+','С') THEN
RETURN NEW;
END IF;
new_zone := npr._zone(NEW."FundsLevel");
SELECT ls.last_ts, ls.last_zone
INTO last_ts, last_zone
FROM npr.last_seen ls
WHERE ls.firm_id = NEW."FirmId"
AND ls.client_code = NEW."ClientCode"
AND ls.limit_kind = NEW."LimitKind"
AND ls.curr_code = NEW."CurrCode"
AND ls.tag = NEW."Tag";
IF last_ts IS NULL THEN
should_write := true; -- впервые видим клиента
ELSIF last_zone IS DISTINCT FROM new_zone THEN
should_write := true; -- переход зоны
ELSIF NOW() - last_ts > INTERVAL '30 minutes' THEN
should_write := true; -- heartbeat
END IF;
IF should_write THEN
INSERT INTO npr.history (
firm_id, client_code, limit_kind, curr_code, margin_type,
portfolio_value, init_margin, min_margin, corrected_margin,
rcv1, rcv2, funds_level, status, risk_level
) VALUES (
NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."MarginType",
NEW."PortfolioValue", NEW."InitMargin", NEW."MinMargin", NEW."CorrectedMargin",
NEW."RCV1", NEW."RCV2", NEW."FundsLevel", NEW."Status", NEW."RiskLevel"
);
INSERT INTO npr.last_seen (firm_id, client_code, limit_kind, curr_code, tag,
last_ts, last_zone, last_uds)
VALUES (NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."Tag",
NOW(), new_zone, NEW."FundsLevel")
ON CONFLICT (firm_id, client_code, limit_kind, curr_code, tag)
DO UPDATE SET last_ts = EXCLUDED.last_ts,
last_zone = EXCLUDED.last_zone,
last_uds = EXCLUDED.last_uds;
END IF;
RETURN NEW;
END;
$BODY$;
-- (е) Триггер на "Portfolio" — AFTER INSERT OR UPDATE.
DROP TRIGGER IF EXISTS portfolio_to_npr_history ON "Portfolio";
CREATE TRIGGER portfolio_to_npr_history
AFTER INSERT OR UPDATE ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_to_history();
-- (е2) Повторный INSERT в "Portfolio" → UPDATE без ошибки.
-- Экспортёр каждый цикл сначала делает подготовленный INSERT по всем строкам
-- и по дубликату ключа переходит к UPDATE. В PostgreSQL 11 каждая ошибка
-- подготовленного INSERT оставляет ~20 КБ в памяти сессии: ~10 тыс. строк/мин
-- дают ≈200 МБ/мин, backend дорастает до OOM и роняет весь кластер (14.09.2026).
-- AFTER UPDATE триггер истории выше срабатывает как обычно.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_insert_as_update()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
BEGIN
UPDATE "Portfolio"
SET "MarginType" = NEW."MarginType", "Status" = NEW."Status",
"PortfolioValue" = NEW."PortfolioValue", "InitMargin" = NEW."InitMargin",
"MinMargin" = NEW."MinMargin", "CorrectedMargin" = NEW."CorrectedMargin",
"RCV1" = NEW."RCV1", "RCV2" = NEW."RCV2",
"RiskLevel" = NEW."RiskLevel", "FundsLevel" = NEW."FundsLevel"
WHERE "FirmId" = NEW."FirmId" AND "ClientCode" = NEW."ClientCode"
AND "LimitKind" = NEW."LimitKind" AND "CurrCode" = NEW."CurrCode" AND "Tag" = NEW."Tag";
IF FOUND THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$BODY$;
DROP TRIGGER IF EXISTS portfolio_insert_as_update ON "Portfolio";
CREATE TRIGGER portfolio_insert_as_update
BEFORE INSERT ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_insert_as_update();
-- (ж) Прикладная вьюха: УДС с обработкой QUIK-сентинелов и маржинальное требование.
-- QUIK кладёт УДС в диапазон [-9.99, 9.99]: 9.99 = «бесконечность» (капнут сверху),
-- -9.99 = маржин-колл / принудзакрытие (капнут снизу).
CREATE OR REPLACE VIEW npr.v_npr AS
SELECT
ts,
firm_id,
client_code,
margin_type,
portfolio_value,
init_margin,
min_margin,
rcv1,
rcv2,
CASE WHEN funds_level >= 9.99 THEN NULL ELSE funds_level END AS uds,
CASE WHEN funds_level >= 9.99 THEN 'INF'
WHEN funds_level <= -9.99 THEN 'CALL'
ELSE NULL END AS uds_flag,
GREATEST(-rcv1, 0) AS margin_requirement
FROM npr.history;
COMMIT;
-- ===========================================================================
-- Retention-политика: cron-файл /etc/cron.d/npr-retention чистит записи > 2 лет
-- раз в месяц:
-- 0 3 1 * * postgres psql -d quik_export -c \
-- "DELETE FROM npr.history WHERE ts < NOW() - INTERVAL '2 years';"
-- Если нужно партиционирование по месяцам — отдельная миграция.
-- ===========================================================================
-- npr.v_current (текущее состояние по клиентам; снято с рабочей базы 14.09.2026)
CREATE OR REPLACE VIEW npr.v_current AS
SELECT p."FirmId" AS firm_id,
p."ClientCode" AS client_code,
p."LimitKind" AS limit_kind,
p."CurrCode" AS curr_code,
p."Tag" AS tag,
p."MarginType" AS margin_type,
p."RCV1" AS rcv1,
p."RCV2" AS rcv2,
p."FundsLevel" AS funds_level,
p."PortfolioValue" AS portfolio_value,
p."InitMargin" AS init_margin,
p."MinMargin" AS min_margin,
p."CorrectedMargin" AS corrected_margin,
p."Status" AS status,
p."RiskLevel" AS risk_level,
CASE
WHEN p."FundsLevel" >= 9.99 THEN NULL::numeric
ELSE p."FundsLevel"
END AS uds,
CASE
WHEN p."FundsLevel" >= 9.99 THEN 'INF'::text
WHEN p."FundsLevel" <= '-9.99'::numeric THEN 'CALL'::text
ELSE NULL::text
END AS uds_flag,
GREATEST(- p."RCV1", 0::double precision) AS margin_requirement,
npr._zone(p."FundsLevel") AS zone,
hb.last_tick AS ts
FROM "Portfolio" p
CROSS JOIN ( SELECT heartbeat.last_tick
FROM npr.heartbeat
WHERE heartbeat.id = 1) hb
WHERE p."MarginType"::text = ANY (ARRAY['МД'::text, 'МД+'::text, 'С'::text, 'C'::text]);
GRANT SELECT ON npr.v_current TO quik_ro;