From da12a9ec1cf0a81468192090ffec082077003dce Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 15 Sep 2026 19:41:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(bj):=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=9B=D0=9A=20=D0=BD=D0=B0=20PostgreSQL,=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BC=D0=B5=D0=BD=20=D1=81=20DEPO,=20=D0=B1=D0=BE=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=9B=D0=9A=20=D1=81=20=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=BE=D1=80=D0=BE=D0=BC=20=D0=9D=D0=A0=D0=94?= =?UTF-8?q?,=20QUIK,=20=D0=9D=D0=9F=D0=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + DESIGN.md | 77 + .../tehraboty-TEST3-daty.docx | Bin 0 -> 12925 bytes ...на тестирование с участником (приложение 1).xlsx | Bin 0 -> 12310 bytes ...я по подключению к тестированию с участником 1.docx | Bin 0 -> 14640 bytes .../Сценарий тестирования (приложение 2).xlsx | Bin 0 -> 24958 bytes .../Тестирование при подключении.pptx.pdf | Bin 0 -> 408985 bytes DOC/naufor-spravka-standard/Pril1.docx | Bin 0 -> 15569 bytes DOC/naufor-spravka-standard/Pril2.docx | Bin 0 -> 13942 bytes PRODUCT.md | 126 + cmd/bj-lk/main.go | 447 +++ cmd/bj-lk/templates.go | 311 +++ internal/lkgateway/admin.go | 394 ++- internal/lkgateway/adrecheck.go | 227 ++ internal/lkgateway/adrecheck_test.go | 55 + internal/lkgateway/advanced_gate.go | 86 + internal/lkgateway/alerts.go | 326 +++ internal/lkgateway/auth.go | 1308 +++++++++ internal/lkgateway/auth_license_test.go | 66 + internal/lkgateway/branding.go | 91 + internal/lkgateway/cacerts.go | 2 +- internal/lkgateway/checks.go | 62 +- internal/lkgateway/depo_check.go | 538 ++++ internal/lkgateway/depo_check_test.go | 16 + internal/lkgateway/depo_live_test.go | 79 + internal/lkgateway/depo_sim.go | 127 + internal/lkgateway/depodb.go | 220 ++ internal/lkgateway/depoevents.go | 1036 +++++++ internal/lkgateway/depohealth.go | 146 + internal/lkgateway/depojob_live_test.go | 23 + internal/lkgateway/depoqual_live_test.go | 42 + internal/lkgateway/depoqueue.go | 315 +++ internal/lkgateway/depotables.go | 431 +++ internal/lkgateway/depotables_live_test.go | 128 + internal/lkgateway/depotables_test.go | 38 + internal/lkgateway/export.go | 444 +++ internal/lkgateway/fancy_bundle.go | 652 +++++ internal/lkgateway/fancydb.go | 130 + internal/lkgateway/fancydb_handlers.go | 321 +++ internal/lkgateway/fontvielle_logo.go | 24 + internal/lkgateway/health.go | 290 ++ internal/lkgateway/incoming.go | 347 +++ internal/lkgateway/incoming_answer.go | 153 ++ internal/lkgateway/incoming_draft.go | 81 + internal/lkgateway/incoming_export.go | 231 ++ internal/lkgateway/incoming_handlers.go | 236 ++ internal/lkgateway/incoming_notify.go | 198 ++ internal/lkgateway/incoming_order.go | 380 +++ internal/lkgateway/ish_version.go | 218 ++ internal/lkgateway/journal.go | 113 + internal/lkgateway/keywizard.go | 85 +- internal/lkgateway/licensecheck.go | 144 +- internal/lkgateway/licenseclient.go | 207 ++ internal/lkgateway/licenseclient_test.go | 137 + internal/lkgateway/lkapi.go | 383 +++ internal/lkgateway/lkconnector.go | 612 +++++ internal/lkgateway/lkdata_test.go | 40 + internal/lkgateway/lkemulate.go | 289 ++ internal/lkgateway/lkemulate_test.go | 61 + internal/lkgateway/lkflow.go | 387 +++ internal/lkgateway/lkmatch_live_test.go | 147 + internal/lkgateway/lkstore.go | 319 +++ internal/lkgateway/lktransfer.go | 256 ++ internal/lkgateway/lkusers.go | 148 + internal/lkgateway/m2mcodes.go | 172 ++ internal/lkgateway/m2mplan_export.go | 530 ++++ internal/lkgateway/m2mtest.go | 892 ++++++ internal/lkgateway/m2mtest_bundle.go | 232 ++ internal/lkgateway/media.go | 408 +-- internal/lkgateway/moex_logo.go | 16 + internal/lkgateway/news.go | 87 +- internal/lkgateway/npr.go | 568 ++++ internal/lkgateway/nsd-certs/manifest.json | 24 + .../nsd-prod-gost-shlyappo-20260821.cer | Bin 0 -> 2812 bytes .../nsd-test-gost-petrov-20240924.cer | Bin 0 -> 2681 bytes internal/lkgateway/nsdcerts.go | 609 +++++ internal/lkgateway/nsdpoller.go | 154 +- internal/lkgateway/orderwatch.go | 232 ++ internal/lkgateway/outgoing_export.go | 128 + internal/lkgateway/outgoing_order.go | 416 +++ internal/lkgateway/quik_live_test.go | 59 + internal/lkgateway/quikcycle.go | 621 +++++ internal/lkgateway/quikdb.go | 375 +++ internal/lkgateway/quikdb_handlers.go | 225 ++ internal/lkgateway/quikexport.go | 1240 +++++++++ internal/lkgateway/quikhooks.go | 410 +++ internal/lkgateway/quikm2m.go | 282 ++ internal/lkgateway/runtimeconfig.go | 330 ++- internal/lkgateway/russian.go | 40 + internal/lkgateway/seedstore.go | 107 + internal/lkgateway/server.go | 175 +- internal/lkgateway/service.go | 214 +- internal/lkgateway/setup.go | 984 ++++--- internal/lkgateway/types.go | 44 + .../lkgateway/web/quikexport/export.ini.tmpl | 1301 +++++++++ .../lkgateway/web/quikexport/exporter_pg.sql | 2429 +++++++++++++++++ .../lkgateway/web/quikexport/info.ini.tmpl | 13 + .../web/quikexport/npr_migration.sql | 250 ++ .../lkgateway/web/static/npr/chart.umd.min.js | 20 + internal/lkgateway/web/static/npr/htmx.min.js | 1 + .../lkgateway/web/templates/admin_claim.html | 228 +- .../lkgateway/web/templates/admin_claims.html | 10 +- .../web/templates/admin_depo_sim.html | 139 + .../web/templates/admin_fancy_queue.html | 76 + .../web/templates/admin_handbook.html | 105 + .../lkgateway/web/templates/admin_help.html | 12 + .../templates/admin_help_architecture.html | 10 +- .../web/templates/admin_help_crypto.html | 8 +- .../templates/admin_help_fancy_events.html | 243 ++ .../web/templates/admin_help_quik.html | 163 ++ .../lkgateway/web/templates/admin_home.html | 80 +- .../web/templates/admin_incoming.html | 51 + .../web/templates/admin_incoming_detail.html | 381 +++ .../web/templates/admin_journal.html | 25 + .../web/templates/admin_keywizard.html | 4 +- .../web/templates/admin_m2mpreview.html | 128 + .../web/templates/admin_m2mtest.html | 197 ++ .../lkgateway/web/templates/admin_news.html | 14 +- .../lkgateway/web/templates/admin_npr.html | 32 + .../web/templates/admin_npr_client.html | 47 + .../web/templates/admin_npr_rows.html | 7 + .../templates/admin_quik_instructions.html | 99 + .../lkgateway/web/templates/admin_setup.html | 924 ++++++- .../lkgateway/web/templates/admin_wizard.html | 6 +- internal/lkgateway/web/templates/layout.html | 340 ++- internal/lkgateway/web/templates/npr_css.html | 14 + internal/lkgateway/xmlfields.go | 91 + internal/m2m/messages_test.go | 3 +- internal/m2m/validators.go | 10 +- internal/m2mcore/enrich.go | 21 +- internal/nsdadapter/igw/pack.go | 62 +- internal/nsdxml/codec.go | 187 +- 132 files changed, 29969 insertions(+), 1089 deletions(-) create mode 100644 DESIGN.md create mode 100644 DOC/m2m_participant_testing_2026-07-20/tehraboty-TEST3-daty.docx create mode 100644 DOC/m2m_participant_testing_2026-07-20/Анкета на тестирование с участником (приложение 1).xlsx create mode 100644 DOC/m2m_participant_testing_2026-07-20/Инструкция по подключению к тестированию с участником 1.docx create mode 100644 DOC/m2m_participant_testing_2026-07-20/Сценарий тестирования (приложение 2).xlsx create mode 100644 DOC/m2m_participant_testing_2026-07-20/Тестирование при подключении.pptx.pdf create mode 100644 DOC/naufor-spravka-standard/Pril1.docx create mode 100644 DOC/naufor-spravka-standard/Pril2.docx create mode 100644 PRODUCT.md create mode 100644 cmd/bj-lk/main.go create mode 100644 cmd/bj-lk/templates.go create mode 100644 internal/lkgateway/adrecheck.go create mode 100644 internal/lkgateway/adrecheck_test.go create mode 100644 internal/lkgateway/advanced_gate.go create mode 100644 internal/lkgateway/alerts.go create mode 100644 internal/lkgateway/auth.go create mode 100644 internal/lkgateway/auth_license_test.go create mode 100644 internal/lkgateway/branding.go create mode 100644 internal/lkgateway/depo_check.go create mode 100644 internal/lkgateway/depo_check_test.go create mode 100644 internal/lkgateway/depo_live_test.go create mode 100644 internal/lkgateway/depo_sim.go create mode 100644 internal/lkgateway/depodb.go create mode 100644 internal/lkgateway/depoevents.go create mode 100644 internal/lkgateway/depohealth.go create mode 100644 internal/lkgateway/depojob_live_test.go create mode 100644 internal/lkgateway/depoqual_live_test.go create mode 100644 internal/lkgateway/depoqueue.go create mode 100644 internal/lkgateway/depotables.go create mode 100644 internal/lkgateway/depotables_live_test.go create mode 100644 internal/lkgateway/depotables_test.go create mode 100644 internal/lkgateway/export.go create mode 100644 internal/lkgateway/fancy_bundle.go create mode 100644 internal/lkgateway/fancydb.go create mode 100644 internal/lkgateway/fancydb_handlers.go create mode 100644 internal/lkgateway/fontvielle_logo.go create mode 100644 internal/lkgateway/health.go create mode 100644 internal/lkgateway/incoming.go create mode 100644 internal/lkgateway/incoming_answer.go create mode 100644 internal/lkgateway/incoming_draft.go create mode 100644 internal/lkgateway/incoming_export.go create mode 100644 internal/lkgateway/incoming_handlers.go create mode 100644 internal/lkgateway/incoming_notify.go create mode 100644 internal/lkgateway/incoming_order.go create mode 100644 internal/lkgateway/ish_version.go create mode 100644 internal/lkgateway/journal.go create mode 100644 internal/lkgateway/licenseclient.go create mode 100644 internal/lkgateway/licenseclient_test.go create mode 100644 internal/lkgateway/lkapi.go create mode 100644 internal/lkgateway/lkconnector.go create mode 100644 internal/lkgateway/lkdata_test.go create mode 100644 internal/lkgateway/lkemulate.go create mode 100644 internal/lkgateway/lkemulate_test.go create mode 100644 internal/lkgateway/lkflow.go create mode 100644 internal/lkgateway/lkmatch_live_test.go create mode 100644 internal/lkgateway/lkstore.go create mode 100644 internal/lkgateway/lktransfer.go create mode 100644 internal/lkgateway/lkusers.go create mode 100644 internal/lkgateway/m2mcodes.go create mode 100644 internal/lkgateway/m2mplan_export.go create mode 100644 internal/lkgateway/m2mtest.go create mode 100644 internal/lkgateway/m2mtest_bundle.go create mode 100644 internal/lkgateway/moex_logo.go create mode 100644 internal/lkgateway/npr.go create mode 100644 internal/lkgateway/nsd-certs/manifest.json create mode 100644 internal/lkgateway/nsd-certs/nsd-prod-gost-shlyappo-20260821.cer create mode 100644 internal/lkgateway/nsd-certs/nsd-test-gost-petrov-20240924.cer create mode 100644 internal/lkgateway/nsdcerts.go create mode 100644 internal/lkgateway/orderwatch.go create mode 100644 internal/lkgateway/outgoing_export.go create mode 100644 internal/lkgateway/outgoing_order.go create mode 100644 internal/lkgateway/quik_live_test.go create mode 100644 internal/lkgateway/quikcycle.go create mode 100644 internal/lkgateway/quikdb.go create mode 100644 internal/lkgateway/quikdb_handlers.go create mode 100644 internal/lkgateway/quikexport.go create mode 100644 internal/lkgateway/quikhooks.go create mode 100644 internal/lkgateway/quikm2m.go create mode 100644 internal/lkgateway/russian.go create mode 100644 internal/lkgateway/web/quikexport/export.ini.tmpl create mode 100644 internal/lkgateway/web/quikexport/exporter_pg.sql create mode 100644 internal/lkgateway/web/quikexport/info.ini.tmpl create mode 100644 internal/lkgateway/web/quikexport/npr_migration.sql create mode 100644 internal/lkgateway/web/static/npr/chart.umd.min.js create mode 100644 internal/lkgateway/web/static/npr/htmx.min.js create mode 100644 internal/lkgateway/web/templates/admin_depo_sim.html create mode 100644 internal/lkgateway/web/templates/admin_fancy_queue.html create mode 100644 internal/lkgateway/web/templates/admin_handbook.html create mode 100644 internal/lkgateway/web/templates/admin_help_fancy_events.html create mode 100644 internal/lkgateway/web/templates/admin_help_quik.html create mode 100644 internal/lkgateway/web/templates/admin_incoming.html create mode 100644 internal/lkgateway/web/templates/admin_incoming_detail.html create mode 100644 internal/lkgateway/web/templates/admin_journal.html create mode 100644 internal/lkgateway/web/templates/admin_m2mpreview.html create mode 100644 internal/lkgateway/web/templates/admin_m2mtest.html create mode 100644 internal/lkgateway/web/templates/admin_npr.html create mode 100644 internal/lkgateway/web/templates/admin_npr_client.html create mode 100644 internal/lkgateway/web/templates/admin_npr_rows.html create mode 100644 internal/lkgateway/web/templates/admin_quik_instructions.html create mode 100644 internal/lkgateway/web/templates/npr_css.html create mode 100644 internal/lkgateway/xmlfields.go diff --git a/.gitignore b/.gitignore index 356e622..39a1e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ DOC/*.bak.pdf /dist/ish/*.deb /dist/ish/*.SGN /dist/ish/*.exe + +# Собранный бинарь в корне +/bj-server diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d7beb30 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,77 @@ +# Design + + + +Визуальная система интерфейсов bj. Продуктовую правду держит `PRODUCT.md`. +Токены и оболочка живут в `internal/lkgateway/web/templates/layout.html` +(админка bj-server) и `cmd/bj-lk/templates.go` (клиентский ЛК). + +## THESIS + +Пульт управления перемещением ценных бумаг между депозитариями через НРД — +**приборная панель, а не маркетинговый дашборд**. Состояние системы и заявок +читается с одного взгляда. Отказ от generic-SaaS-грамматики: эмодзи-иконки, +лого-градиентный блоб, градиентный текст, сетка одинаковых карточек как +структура страницы — не используются. + +## OWN-WORLD + +«Диспетчерская депозитария». Прохладные нейтрали на свету, терминальный +near-black в тёмной теме (настоящее рабочее место оператора). Один уверенный +акцент — **индиго `--accent`** (интерактив); **MOEX-красный `--brand`** +зарезервирован строго под знак бренда и критические состояния. Знак — два узла, +соединённые мостом (продукт буквально соединяет депозитарии), inline-SVG. +Иконки — только тонкие line-иконки (stroke 1.8, inline-SVG), никаких эмодзи. + +## Color + +Стратегия — **Restrained** (нейтрали + один акцент), корректно для Operate. +Токены в `:root` (light) и `[data-theme="dark"]`. Обе темы обязательны и +переопределяют весь семантический набор (`--accent/--ok/--warn/--err` и их +`-weak`). Тема запоминается в localStorage; уважать `prefers-color-scheme` и +`prefers-reduced-motion`. Контраст текста — WCAG AA (body ≥4.5:1); вторичный +текст тонируется из нейтрали, не «серым наугад». + +## Typography + +Системный sans для UI. **Приборная подпись: все величины — моноширинными +табличными цифрами** (`--mono`, `font-variant-numeric:tabular-nums`): ID, +счётчики, время, коды, суммы, значения статов (`.num`, `.stat-value`, `.clock`, +`code`). Заголовки — плотный трекинг (до −0.03em на крупных), явная шкала веса. + +## Components + +- **Оболочка:** sticky-топбар (56px) = знак бренда (flex:none) + навигация + (flex:1, внутренний горизонтальный скролл, скрытый скроллбар) + правый блок + (flex:none): строка системного статуса `.sys-status` (пульс + «НРД на связи / + Эмуляция»), моно-часы, SVG-переключатель темы. На ≤1200px статус сжимается до + пульса, часы скрываются. Топбар не создаёт горизонтального скролла страницы. +- **Приборные плитки-статы** `.stat`: цветной торец слева (3px, семантический — + `.accent/.ok/.warn/.err`), моно-значение. +- **Плитки задач** `.tile`: слот `.ico` под line-SVG (не эмодзи, не градиент), + hover — подъём 2px + сдвиг стрелки. +- **Таблицы:** плотные, числовые ячейки — `.num` (табличные цифры), ID — `code`. +- **Бейджи статусов** `.badge.`: семантические заливка+текст (AA). +- **Тост/алерты:** цветной акцент только сверху (`border-top`), не слева + (`border-left`/`right` >1px запрещён). + +## Motion + +Один авторский easing `--ease` (экспоненциальный ease-out). Живой момент — +пульс индикатора системного статуса; hover-плитки — подъём + сдвиг стрелки. +Всё отключается при `prefers-reduced-motion`. + +## Refuse (из craft-floor + тезис) + +Эмодзи-иконки; лого-градиентный квадрат; градиентный текст; `border-left/right` +>1px как акцент карточек; моноширинный шрифт как «костюм технологичности» вместо +данных/измерений; выбор темы по категории, а не по сцене использования. + +## Scope / статус + +Поднят общий слой `layout.html` (токены, оболочка, статы, плитки, таблицы, +бейджи, тост) — его наследуют все ~20 страниц админки — и флагман `admin_home`. +Клиентский ЛК (`cmd/bj-lk`) уже несёт совместимую систему (токены, тёмная тема, +доступность). Постранично ещё не подняты: `admin_setup`, `admin_help_*`, +`admin_wizard`, `admin_claim` — они наследуют систему, но их частная композиция +(плотные формы, help-простыни) ждёт отдельного прохода. diff --git a/DOC/m2m_participant_testing_2026-07-20/tehraboty-TEST3-daty.docx b/DOC/m2m_participant_testing_2026-07-20/tehraboty-TEST3-daty.docx new file mode 100644 index 0000000000000000000000000000000000000000..efd1cf257ce31a4ac90e2564c8b38a0debf69c82 GIT binary patch literal 12925 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 literal 0 HcmV?d00001 diff --git a/DOC/m2m_participant_testing_2026-07-20/Анкета на тестирование с участником (приложение 1).xlsx b/DOC/m2m_participant_testing_2026-07-20/Анкета на тестирование с участником (приложение 1).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c0c22f8c6fd2e1474f22480d96137dbd5b748945 GIT binary patch literal 12310 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 F{{eC0b=&{| literal 0 HcmV?d00001 diff --git a/DOC/m2m_participant_testing_2026-07-20/Сценарий тестирования (приложение 2).xlsx b/DOC/m2m_participant_testing_2026-07-20/Сценарий тестирования (приложение 2).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bbe7b41d4615f0ba78d2cb58cb368f32d6715ae0 GIT binary patch literal 24958 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%0AK6yXz`f$&2KMqqRy5#E48T7tuobBla}`Y$XB8n8 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⋘D;t_f!I^0GPRlnuiy06;)agDQN^_Baz0O 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

  • yMR!GI_Ctv&Y*E_q3HO`6Vv#~YJsbUp#xw#2E3RYOL98u1( z3Tu>H=UHlhX)m*v+bisqc9FfxUTv?jH`?FWo9!+3R(qSh-QHpUVE=5F*}Lsu?LGEh zd!NG`#ZeE!ttvdiP7ZIOzEvgPeO75^bH6dcc#2YpqZD4Jp8p2TUBZ~VG(c+UNUb^g za|e<(r@^LBp6Cp-N$}`-NQ@AKIf%l3!sa9!OO=MQwc9(1!5!#!#3nKxI2~0<|?}zd~f4UV=T#~ z|0ButfbpR5g7Ko+(mcyN+dRi?W46V<#Cc**Vt{#%d7t@!IUYL>i_MKxHh))vOMAa9 z3_9>Gg0~@UV9qf)wvOC#bnQC{puVo3mAF^`Wvb0zl{0)Kt#_SWU1{QxiTBaPYWsKT&XVyT3 zZ-Ei8F=7-FCUOa?&(`PYpYfRn&3|XOdC=gVcDcP&Gk>4Y*E`HzB*pq+zYAIcrGFXq zsejma+E3Uo+ArBJ+d1}Zd#?SN&=0=xbpR*eM4U#>IZkIM+l_a_uIHw?r?{uPt=;y5 zu3VxkEX8AHotjdV!gp3kGvqrq`WmL_C8S3NjRwY9 zegAUObqv;*be&V#^`z}|V>g-I&F*ZJd5PJR{g2tp?8QcNivV^9=|Dr+oumVO!tSQN z^d)?CT=|o?4(c~tOSh`nH_!-2qnU3TFhY#^xBP}BK>Ocp z9ZUafxuS?MuZvhYOrVt0XkM_uSj#Njy=73GP1i0OAh-n&Zow_MLqd??!5snw9o&5& zxI=Ic4#C|STn7oRGeD34gTvr(c%Jv$-`>0G{5XHVQ*}-ETC4Brn!BgEX7$|N*JU1F z7oMYl&z6`Yvma}5b}}8`fWI9`+Uj7 zzUS9>2d2btv8}(ddc$Nye)#!cCfKl7sFC3h5ow!>IY#_o*%eB%6!U2J=NYe^sx_!9 z;V09|#~iTMRdL>W6nB{WMmPTr9S|pC7w&UtM)al2-POUB%9JD@a!2*<`jOD%%-b=J zCy&r{=7`b5HFXoq<7qV0XU}(U*3RyyU_#Db?AcIl9K_C1Kon|l%rV@;Wm`b*bmvIW zjsQhh!MkrVkKqQtQ{LeGb*o1skB`v~S`B%i8_IIHJQDK^`yknrdxdrLhV!%fLM zL{Jr9)`@LDv7l=%Y8S3YaLp>1^;bMmVI7O!x1x|aO>%Kt;MFbB-|8Iq!H}h6coL&ccHCDE^)k-uz;P}Z+)}=n z@z-eI*s6vXu#XK#X=v!IgXk`djbEQHFGCa@830E80l&+?Pj;S1oYP`dEJbjR3vH`y!);LS9`9-I5$`4M zf!pbf&216yNjs}J-o;yR#-86X*ppbuL?}ADOye)RYpSTsB&$_Z!w`#y0{oi9#xL}I zPQ_PP>p3Hy-jX#oI>R#6n+0GGFFvagg|qo&6z^IJBs6YH3xHj!+U>Axc+HoEz&xe; z3tO4+E<=_kkB`cTqy#!I}zd6pZ#86e$Aus?RL;TWiVP zOaXC;&3LNA3ai2<#gqffaz)g_s>SOA^M?C6TXwmP;jTI>iVsFKypwvupF3m6&IX`T z!a*0hD(^VcM&ye;O=u1sB(Ftod1k6IN5pNkT}!elZHR;oy2GZDu|yOn?|NqM!()yT}88270ke_P(AiE|A^WUrX#UzjXd zY>H$=ZubX$xHYVg1DLd3Pw4z3;uk)ecXHhMiS{D;+c)m1ijMT)p zMZrY+ChZ>ut1+PW(=i=6>yYC>q8>$SxPO~}=sG4KP{$OxHhz8a*vce)9+YKmDKXYP z_+(P~ydE|W5vwvWCY`A&p zQlC?i19(}Jnv~|b=I|!?#BtH;*RH>#<*@|^cN5vgUX!Bla$b;BmctuRW|oY?Z6z}f zBnB7an;&%h(``X!stqPB&<0l+DVzze)Lr5{#~`_)twG%o`Bu+Z`PnV?MWtSj-% z&oksEe|eMj>=BZ|XjT(dML6H0;+DcR$>xee?G38m4iOpSy}6Xcx?-n;U{Fb}62 z4y5{Ru{ik#DXK}K6~&sZ>ZGCckr1RvoMH2(ww3M_waa1%8kF`IA6xItCjN8HkG9 z|DiWlPNw7gEl2;u0*dEDOQ(?51DhXiaJ!kBEA3piwD)C`LxI?h%9f4hi+=KcSzA{5 zF3+;LrmOu@lBVm?lJu{(YFK$;f!a_zQhzoS-yK3p#*Ji@9&9E~hsUdi%ljKX$Ex~` z#GG;y!?(P!-tIsrczM&0;s_5Fyt>{Hg=_bBIIkR0&2o?~T$jI&jF zdzLtVeZOz`CnqhN!phX{sH0*v_|WNCOii|%Ac1x4hRBO&Wk&$x$0SIfF{=ZXoleLF zWOEY-PJ*Zgzuy_X=x~2~n`-MGTrBy&x}I7S!@w(A(^s#i)YZtYF_OS{ z{E*v%B!`sup|aF4d&D}~nUCa^Aoo*f>sWa*koNd=hD9<%uh~ccx_6-w4??u-`;tR3 z^pPb^4kS!c_8@9MafW{r(`4Wn-7<;8)=yKPShtHUT)9O^K0lfEpb_DvjnrI7u&#-3 zs(5UqvN;forLFW!v8Ok+R6eq?wQJ*j&r`2IxnuIPBL1oPh0U{>r`jW*0#TloJK6w7!u0nhagAOf_%#}Z~ZKlev`|vaOqc4H;-q8XukNLaD!5&o! z5PKgIaqdXk#+G_)ROfaQRV=Dg^dF2HIN#p$ss>h=EEZV&HST|Zt^08Vd{CcNGsPG7 z+7S^wfb8APe!|8CZSPc>X6`8SREa*DYG*zfZ9ne}cs*6>Pk*_bUNwbU^*x};&`$vgfLmH! zNL{GA1_~F29R}@Kq{s63X#gxD-a5F~R^H+=BVv4wL?E5|86X9m^-4SWSw zWT(q~lPyMT=1K(?A!E0AsX?u8gc)XTUY+N|$6UP5KMD|&%b;58G|5PomWBTYg}DUI zW)aN}clZB&D7#g_He1Xk^0CNl3EUl?(0P{T1L9`py*If>nl)V3_hnm2L0B)b>=+eu zU>aBtYqR_D(bb1u>Ea{u#YcjRUY~SafkBFT(%l5BuQ- zHt{mwu%V(4VE)pB;-in`TS*O{ju>&i{-r7EXR!=w-TCVg+`M5)v*9Nze-imDlbAO) z!+spY1NtiBl{c)?zrTun_Y{AEm8F_d!}|wqW(iy3wp3{YoY1e(4|Lri8L}#J$7L&lo&a|q zAtJq74XjP-Yw8ur`w&dTXNTb)NNc*3{ zD8nhXUM(Jw>-_veivgn97oD51T2vQl+03q5`K?4qzx9h4)9J0|6RA*4!*y}0RB*Ks&N#akwCbefEOwTa zl;kbR^o@UtGZ9>a`2D=DCA&GoOQIT#9LrM}GnjE^DHbi51b&zLwIs8g{kNU9z&F~4 zyybX?j9~D`7!Xi2&gSK0DV?H#RYrr}lHpg*uOcQ;voJl|)R~cn(X|2gafVTw3x12; zO|c|RKyXtKUz_)_mgAV@ zWpjSlK??faNI|Y8bY^L(V=~1npBc{O4t=c~yW{V>{Psg} zTBiC={S%9Zt*m-^-CTBU+gwy_O0rBkgZn!evpWNf)kw-KaxSU1G+8;FhC_^QDgA=*=~Nhy;6^YU zNPc62{akV?iS%T@3B-O-4F+P~SoJ>Z-{fI>Qn#-5u9FV- zU$st!zobk#n^ahys;zQ;{>W?3f%k~5;XVGeYl1!gcx(L|$2ixqP9|8_POZK@{se1+ zNYp`73_TzOE>sU>9GP?nr{^xI8hA_|KQZ{zj`CNgPc<9Qq)&oUHg?Hjc_yOR#pqOY>npl-LQXb9 zSKanqT!*bGU+O!i%yYL{jI&8j7`$kwF72kS=#0g=CIGK|`2*;5+T8tNmvoW9q)#PZ zp0{{%?#zgyU0H%21Bmyse_WKO!CI)NDn7^@H8y$as_^QnF!j6KVI}Xl=&G1tAGQY7 zW_ME+-&lpJ!&;E?Zc8~)OLOwrHwTvjDN-*T#syA{GcR>2g>)UiTRKz;>pC8Xw@uQ(FJv_qR)fbMD-jB`D9zzl7W6c`p16 zLA7z8+r7E7;afQPwwu8Bl;RfSeu;^&!KF(WovwDi?+*(7-GJ)SEWT2 zek1mdRpt4Y5F@3Vk}xxJib`Jc!CXPpuApq#Ej@gab+%}pOu3|63u?pnBB5tVo=0{V z8#^SRAuz?WnwgfRli?30pE%N1-!A$*Ys<&igfcdvC9O4!6Aw z1x%4CKpprhWK|W8nndZLOK)rlg<~7DZeeU~S!cmX|}Vb zF?S^ck^{!RTD0;gv?-HTtW}6risdV*=jxQOIxVNnq(a5Rb)Wn=8vaQqq6;S$A_C)ptNj}J_2d4;S28lGNB#1aj`-OUY_`_lk zLr`uI>;tGLp{KTIq{lv}F6bVK08te+7gq^W3Ad9n9WR|Uog@N9`*jpjagZa@DxxS- zM=*OW?^`TJ2LekVa>q?=Qh6z4J?I6G0}LNg(+>ke8SNQywfV=aNqAiMF;V45 z;j!Y@U2JUCT}Z>k1|~oKcR^4mnP0C6VRDiQ8oW&QJ9A^qoeW{a*jBaLD&L-<%hNl? z*zk!P0T8zeW1d-cC<^@mZBGn6^L)i~;>aEB??P&gRc9*t^5h>2WO};27TN@*&SZV! z-le$~9`VO<$qQcFqq+9m&W&HYI0IqnzBY`uI&(UCb-s2Xb9_s8?U(4=JtA(h{lqe; zyAgoNj_Be5#2}M!2flui-rW5GpzY(3ZVg2fT*P#hZfzq}flJUou~PIdPi-^|UVj|F z2OF{Go(%ej-gmWLH15&D#qfNo{F-khBE)g)_9B;F?$(BS;`m9%$DEDFhywrcQbJKE zha61Jzn<|%zWj6E;Hk%%725@yx1(Pj8hgh$sAxKxa-bMnRHCj}o--$NzItUk9oc5&t+ysp}Mif(m` zJH?-faEcTTo>3{f;Y|?mW0rwh?FZ=G@+z!?NTTkwou6ga1ZR!l5>hJEZEDU?w&MU{ ziKvu6^k2Aq+iud>`u3tu+vxlp*!?*Bo{Lc*i%}^>#Ydt50ZWXtx@6hhXN@imN&Yuwl#06IX+aQ5(P#f zew0BdV}3=1{IlSdCgDGRXr6D7LBMtSe+SW6G4e+V`v!|m@~6LHa9a}7wMcDiqh**x zxM7AeHoUG&>lrlX!mCT8f#VMte_v93UySL2EiR*C`|Z3@#>-&lWv&8~S3=;l8K|d)?~W|0gDAbul^2st!AZTcSUF z!n@3(`}?tCqzPwpfp$$K#31yyKT%)RcdACROt5YKXl2+GF%*@0?QnX0C>n3}a1a$n1b8{(c8-M=3hKv`7D?3X{c)Bn4om<|3B`mgsbAC&=9oLTSh z$I+O>kEZ#kq2@kcUf#5sPDL2E|8L->TjGCXFRf<_=Xfq#K40pm|Lx#E{S4TQ4uTBv zBy>Wih&El#H(l|%>(|pAZHk|1kA_$cBF<{Q@?8pek$2T}DMJJyEfC8SQOD@(u9o@L zUHfYfFG(*+A4zY~7X}d?$F!E!`@#G9)jvm9a2Z|cs1ZDd0$IdFl*CK47WSkr-Yz!g zE4fa&rO3VlMqW#`LhVMIu=V3`>D#xJi;55wh5R)KHuitNpKSrm(iLh~L&T{tPU4#AaC6`dNBhb3B%3~z>xuAJq>sGkuK{VL- zQ%z#prbSe;Xl-gGrb_3LK2W_M`obt@NdCF= zctZ9KuH)C8llKbN!E(KFg_x_r$#sP|Z8E3k5y2dO2C`W_K!H}a6kE;`RVUsrfod|K(4C-7z*s)Z<~shy>D|?a#>t@4;ORxMSPU4H&~YT z5NxCR7Cb5**2ga{%a5BGL-~`1NLM#V0z|A@p;GLb|FfP!Em@v}C&@<|^%ACZusJcZ zF5|Sjjd8U7*M+yNxKq_W4AdYHw=$Jw{ZV5&%#XxTYq*}0Lj-LeGcFLWBJGc9%gs@y zb!ApK9T)bK;g%E9JZ7LF8@k{IvpxlV-ULN*=|wnlR2F>9qstwAM?_Fb2S{mJ(KjbD zU|7%xGk3}sFptQK2z_R)Vb7gOTR0$qbS97 z6($i?T+qu01J`p+j^0X&?|#-mPxuDsdNNuUkjuuA%xQW!7^PQ3U(Xc3iv*o%rd{?AdYPb|#Jn|%6^#jxksr#k{KPvBi}u`T(X&~w zSqNXiVw=)8d+rw=CY`z(i}=`O#a;VK7tIje5ZQ3z_g^w+w37(k5L_9%K6xk5=x;kT zaMYJP{(Hn1(I-ZA-j=ggZ8BW*9F&KIdjsA;Z?E={jw7Ix+ay9#Xgr6El+LsWrLd3rf+e zG#7#%Np$4{gQb_N^zIqIpql{^7rwy>+5W}$GpaKVHhB~YpImgCwN)6H`-ZqgA6-(_SMf z(_)igV@qR8lV{U)6NP;kyiPzPt7-?}+T^|BXP@8vIodXKMcqE5S&D5Ys_CS$YQ^IS zH8yXA$P&TkYtM85`f;pengUTmbt+03_3P^at3d@;#YI$|O%u-N8mFrFt&U74sA`85 zOod$+Da05odGuB~H=aRaA!aAvEL?bRmD-5J#p541Q@K)n$8^S)U4Y&ZVq$eVTSu1< zfjV17Va32XG1LCN()f8NsU=|~k;BNv zv6HIwV=8s31U@b6&`;P_xRzM%qSMBPIg&P#R{Qg10ol8?&9=>^+qq_NJb}Mmu~VT} zAohW(N9z2GH?!go#~+Sn?)WuIoj+_M3kl>Jwg-IinQEQ38X`{`<_7d44fMtb)@dWL z(H?o-aPNJsrL09d_%A3$I$iBizPm7B)oPb){FRX3= z7hlp8J#rO){FD*!MqL4;CYan@COyxa3hT8wnGv!Lv2n&0noVl@*39#@x>#HvF1
    =l2{nG0WY$y=0}9!J_E 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 LBXAf7{tEnGYL+Dq literal 0 HcmV?d00001 diff --git a/DOC/naufor-spravka-standard/Pril1.docx b/DOC/naufor-spravka-standard/Pril1.docx new file mode 100644 index 0000000000000000000000000000000000000000..d0f4f417d454eb343346d72efe85e47e33b4187e GIT binary patch literal 15569 zcmeIZV_;>=(m%XoXM%~%iEVpgJDJ$FlZkEH_5>5#_QbYr{d3N}=RLUh`S5;zp7mj^ zy;s$*dUx-t?&_*8SqTu(&j4@$Bme*)0IcUvTc`s80Qg`405Sj)SVPd-%E8deL0i$) z#?by7jfzs8Cr-H{E>A|Xnd*e!w)*Z&2TapS&i z4J2ue#D-j^9s&zqD;o(Wu4bOu(@K;O-C)svM%6e$(qE_UbI6g$;cQmCN0Mx`-LjuO z&P609I&ZS9N+iUI1D*qu9OYm?VWG2bNm!MKqNZz8bv~8!GWN!eQUOWb(-O51El|w( zN@{L}YR+orI~hb_1W+rN@b)s!C-RsLOaSM;v_Fa@Zo3&z27$ z0Koe@2tf94CW#w^*>L`$XVM=Q2lHW)+IEH(_Ovv=-T%)N{}#)2mz@fCW*YfU^f5u%C)cn{OwTp>&Wy>+@za*x`^XO{I?6t zL?yV;HmF~ZT0I)ituBCsfYt(fmtQtpuxWdDMz6y}V&ubqVQMI$6PVyJ55dV}oyfZs zLU+4GFs6j$6SMlnwRxGgO7r*0UX0l(^mD(=L^a$YO1XvG196(iF}$FScz+}@Qb$L1 zuho3(RXmDoWQZ^$zoo*opra*8kI^fK#=K~qI&#bFDC!Fdpn}SQ3ZZ@WFw#U)f7=h; z!{B8(Cs3=?-n7O_|Hgm)u`vCoeVA6jrU!ol0LIDy0K|_$akjQIpf#}8ceMOiw|-l& zPQGbaFAt)4(N4be9gu@#hq21VJ8cJ)j}Y>;&}~saNlMfeh#-N5M#g!HuZ+2ctyXH( zfuRcD%V%4A%3)K+(As*xg6(J@&>pfIiyny##ixTNG6U&}?Tl_}wmGgDKUV%)EVMYCC5zZqQ{Ih7Y`BrwlSfS7#w;9U#Vkdt zu}OasuE!_q&ZiLNgYrLNN~$CIeawcjf0yAjutRWMh| z!3A)o%6juexJ4V*WA)&Z#BLOIq!!W8iypk5S&zzhW=O>K!g2W#btBn?(Y=ghW5Khl z-#404jk0YEl0lImqX$4u3>ir`As8IAMQT>?a>$BM?M*=KRI9Sn)jMMPomwbf7ErqAw8E%8vo zF$^9-%xt0?s#KCIN~eH-n#b4PJEtXDTFmDN(Q$$DHS%$Le+MFa;MX1-aKL$1=Zh38 z_cN-qRLa~ihB+%)Ow`4?2Z|?Gk{J;KZ}K&U@JFGpxpJYPM9Lj7{7^Xb^) zgk701WYJ8j*utsEs3Burc8Mw8=j5PmCn5TVHxuYKh!8e$LwSN;Gp4fN=aYOtYNvG9dl6n$=olHNovn zyQ*R}?+LQr;!CU}5@cu`@!97xwvd8ExP==1d)4YompAsRN{#&B=PRsX-+Lu-l&_B_ z%fMilMmOsboAGMUVLh7a$Sd!SIQPE6+50#Z&=;q^JhW{f=aZ*y6RjZk3vHjz8m(jK zKy^alNqGG8+fcfmk$ALctVK^UfwEftcj9*_8?NPG1;mOZO0_74u=BC=xA7Bz^1nNC z0(v3=kiCaT{!*%IQ=I(TT1xTVBB6OvdDhsT(AnqOJaAg$;k=ub8?T6!PAl_ktbBPm zqK<%?$Qs}RZbVr+(bGojR_kcTRkY{^L6wexbz_Hf2b8SvPcAT9bi$tkfYo5ux>BE= z;N-jYOB}w~(pBx)g;JDK3~&v+u
    zzTCs^ct7FlVsC(Zp8<8Q;VZ5xD1y@P+aI{e zfMj@ApSP0$@c7Q`&o$0vcoeHJ$&hEm*mM#T^u)<`3zmV6;8$FI(l80l@sG5E1q=JN zB6`I9sXOP9MY?$_ zu=sA9wf4>^Krkv82lPSrvz%A$AbY3HXt16^gL9HajnD;)=wt~D1-VrF)?;oFc@A3f z;*=KMXY^c1ZyfE^wy>ocC9-h*Y=B>E@g;>0-`1i-vBT+~oS*Lm<+Xg6Q3%82NKNB? zGCBB%dL2=TM)Z?!WDvT_AhnI`A!+osoPmzW&AUUuqTHw|?zNr*;Gn%uJmHi$a7Or) zdT&XK_mlDmJGV{E*zuCJfPIL)Wb?J-5Oe0`mPiVcDTnkjKP{7F3FbN%O=rKo(CO-7 z2&i1_lgdTAv#a-RW{MU~n@(n0X*Tt2MnUIxlr|kk;J{@m?Xc&|D`s)>U}Ow%By1V-_2J=+wa^r7-kvy zzjECm^&n^SX^C_Y2aMa1pz^#=kRayy9e4@`Q${Nmmp6hl(WWr?ZnOo;D%?kP2KT-T z9ozdvOY#vvxK6BYp0=5M9&as!DE^XTyjD90Bxr}tR0OU5BboAQETZ6REirKVdx)ZB zuPgX>3=+m)V0@k-tW{mJM6x}5mh$G~WFGc6_XUU%*j+Chw<1v;Yey&*C?Diz$c)88=t88N*bVoK!( zr5IbG4gc6|z%#@Rn1K_4`^h+uY01n>gadk5IOQSSq-#CKe$u|s-+$i2hkKCgu_?*V zEY{gS(Khh_9gZyN07Kjlc7W9@ug$uV-b|{5$25gS=Q>Bn*qqMbVh;G{zF#f)D?;%NsQ3BuB_E|V3dUjj1n--Z@{2`fU8RMk6|zXl ziIWLnNlBl$Ym-Mb5SSmug8qk+WATk+c84W`YKUjntM62(CRef%FQ>L!``IaIOh?dZ zsL3$W594d48=j{)D-KKA+Om34ySwqjhQxAvsB;B)x#Z}%Ce2*;?L_*;)}!nyvtCtS$w~`bkG*3COK^07S`bZ?&6Vl9B=5$kyP*Q+wc%E^n1?aX`U$SG>O&tpHW%5;z8mfjqP=2==ash)G*su+BC0BOZ#9 z*(LbdpS6bfDZy9T*(B&Aj(%oTBWF?*-xtC~usC!KbcE8U-dqNQIC({tm$^Jmgnxq7 zt44KL;mPr4y1XoicaT$=#@5Ex9yMi#Z-05+WnDvT!}nhP5AX0G_{GNVji(RQ z6$IrywUs*)qAUNI{DlnO1xDMGEdaI6Aqq_)EOg(q2$dM;li_x6%}yMa+aP@NIvn!w z0YamzpJ#b6%+y&eIOmo@TdP5Z5d(?Tym)$f6DoQEST2Yof|xI1m@$MI^(xN@T`MR%j<6ibeYKW zLgH%KT)>54U`vw3qAB-dp@HDL9NtDs{c-whwCRv%`#bJI+!#bx6tiI9@;kB-xISol0Y8ScJU9>A%*6S6&Ma zG=X0;Ea}zlgKEx3_K9WyMpdUr29C;1@~QyZh7u(ox9pnL97D6JBKDS@E#mtX8$q2_`0 zc$rhuN8%>gsQIw0HTFc$oGAg;VKLlBJ43#?=11TSMYPYvjWlJH2gnQd>yc@mMeM0x z4F`cW;=bU*=jg9+;HEN6?uL*SP$Ey&I1D#g89Nw_YCMG@Ls3k;ZGI&?n9XWUPD4qH zh_qqnt7XD?TU2ApE3`T{p&s85E4psZw}HEQh0C-Yaa*7}3DStv<&16AdRFP!qu zwZ?Q>qVn>eO-qxnMF?BAa4$IUvO0m99i?oqw3nlV;Wi^0&X zbXBs^qOoB2jE=!8=uWyw;W6(ZY)q)p(!ng7t6xtS;hXuc_OV+CxH=%G*!j83rkwY^ z!RoA2iky0@OKY*zR1ty46mglTMPJRI^nL#KsJH7DL@PzDAS9e*K~nUk}42%qarh`xGeu1-iHqMhb4J!=PUo+p~_nqDl1{`+zL8 zaMlUKz_u4QF&42UULPrc8S|}&MNJvHku|r3Z#x9>21dEc`yk04cf=BYP_zi$jWDN3 zSVSf7)r(`t<>vb)U0VciU*m)>L%yI9Q=vxz2X03JPe>U7 z3A9X2>FlvzFZ?l=N=oZ&&P7JV6>D&g>tdqfBibZXgt0e zQc@e$gimGKw5vce-!dP?)82=dxoK#-ieO~ zu}E$tB0oKy2DJ|m7{k^_%I4FAxJ@5BxvOhCI0bYNVfsU!VlI@PFyxq+47~p9I`*r> z&GA%@y(1GL$Vuk6gLAouB+(pj@c47GQx}4p+w=P<_^3==ZXS-8SKPX+?)xw5PHZ$~ zwDib8yW>2$)M$ygHGbbbzBa=P3v>&OP#^L(xZFwFk_+vEFQ#j1zmM(i0r%Jnk6hYV zlOMRYtC-W?ngbG!8@1Y0j^`4em;i9#)z|T1CAC^mt?Voi;%TU>t8NYIEEnvrbIcyg zDtI7O{H8M+J@?Q2Mm^YMmQUs`9Wy5mQm>ftEav79ZY8U?N8nn1gyON6n9V!$rCoXr z5`)20Fv>17_fNOO*qJ$@O(qh%$d9Ez(DIhU@A>&SvBYB8>{z;pm;g-xxtf8HTH$U; zU|U}#Qk_Oc@>>a|hbo>-E7MPTGw8edGdVu0Sn@B}^cx0*8fPSvM|=1aSzNA8@0YdD zyu9ug8;h@ad*)Rm-}uCmlb(;4W!}9UCLXT`s@~ilQgCRm9?;gm-)tqVze%+*nRt3V zY+Y_%)AGD+w*;ZOq~*17c_j6!vZ-A7*k|-r^N#?#M0I5LTKS7=L5ApSw zhiDcVbIFmL`SdPEMnw=_EEkNP3ukm^3){o)rHIqvge)oOnMYl4rAR_6!sR5d2dRmP zu!b@)@X*20khpO4hSS#&V07b}hK<2jN1}~Rtkw%_7liD@`dM~R^{ApwYHSg5T=aG& zW67f0$odg-2b=oy6F?bdf{*<0WNW5YS)o`~Uax9BL8ha*`DRDsmm@)q_FU7a5W87+ zEpf5Pe~yJPb8yT|LY`8GlXLJ1hG$@*S-P;$TV%$xf96+i^0xfhVeOx1jp8j{NdabZ% zMCDT2lS_{6z;~_PaG%lBcYHr>4REAq%4H)oIei~09VbLZjGqoLgn`eDh>q!X3$qlg z44*9b0=LAckmGg=P$tDsw>#RFASlB3wRviEqKejjfPCan7vm+BkJF?Y=SoR+JVe)C za|^=?hnQjSgTdV7XVPbx-k(#AW?Xqn45TSi;>7!Du23mb%3Y?S{82x7)g?Y8WC2G& z7I4~$TqaH$Mtf`0o%Cd%nUIVG$;LsZUhl$b4#rqXE3hGXw z0}6=8{duH}?0{)qtIMHIL!0UD3y580`>YpcOSWvlp^mffo_XEAu_HrZT*|%#nOmW%OV`niwHkAfhH|SNPF8)GZ?zD~K|#o@>aeL+lJCwnx=>~?e)`_k}f%)wE`=zDug zn=Jcbg!O6?Oi765^oos7vBpd$@7fwkmVUynr=L=4vx@yU`

    vRU~UKf}LW+FpSyH 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 literal 0 HcmV?d00001 diff --git a/DOC/naufor-spravka-standard/Pril2.docx b/DOC/naufor-spravka-standard/Pril2.docx new file mode 100644 index 0000000000000000000000000000000000000000..555cc0903cd5052826094a353e2876d7b5464a17 GIT binary patch literal 13942 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 + +## Platform + +web + +## Users + +**Основной пользователь — оператор/бэк-офис брокера** (администратор bj). Работает +в веб-админке `bj-server`: настраивает подключения (Fancy, QUIK, НРД/ИШ, ЕСИА +Финансы), мониторит заявки на M2M-перевод и их статусы, читает журнал обмена с +НРД, ведёт пользователей ЛК, обрабатывает входящие переводы, прогоняет тесты с +роботом НРД. Контекст — рабочее место специалиста депозитария/бэк-офиса. + +**Вторичный пользователь — розничный инвестор (физлицо)** в клиентском ЛК +(`bj-lk`): подаёт заявку на перевод своих ценных бумаг себе же в другой +депозитарий. Актуально для брокеров БЕЗ платформы Финансы (профили «автомат» и +«полуавтомат»); у брокеров с Финансы клиентский ЛК даёт сама Финансы. + +## Product Purpose + +Bridge-and-Join-s (bj) — шлюз, обеспечивающий переводы ценных бумаг физлица +«самому себе» (M2M) между депозитариями через сервис НРД **MOEX МОСТ**. Это +регулируемая операция (ФЗ от 23.05.2025 № 124-ФЗ, Указание Банка России +№ 7317-У, вступление в силу 01.09.2026): депозитарий зачисляет бумаги клиенту в +другом депозитарии без встречного поручения, за минуты. + +Роль bj — **протокольное плечо НРД/MOEX МОСТ**: собрать `M2MTransferRequest`, +подписать транзитной ЭП (ГОСТ через Интеграционный шлюз НРД), отправить в НРД, +принять `M2MTransferDecision`/`M2MTransferResponse`, вернуть исход в вызвавшую +систему. Успех = перевод исполнен и клиент/оператор видят понятный статус. + +## Positioning + +Незаменимое ядро bj — именно **междепозитарный обмен с НРД по протоколу M2M** +(XML с namespace-префиксами в кодировке windows-1251, транспорт ИШ с ГОСТ-ЭП и +шифрованием «закрытого конверта», тестирование с роботом НРД, коды отказа +M2Mxx). Соседние системы этого не делают: платформа ЕСИА Финансы/winr закрывает +ЛК/портфель/подпись клиента, QUIK — блокировку позиций, но обмен depository↔НРД +не ведёт ни одна из них. bj встраивается за ними как шлюз в НРД, а не дублирует +их функции. + +## Operating Context + +**Три профиля развёртывания** (bj во всех — шлюз в НРД): +- **Через Финансы:** ЛК, портфель, подпись клиента и блокировку держит платформа + ЕСИА Финансы (winr / ef.winr.pro). Стык — реализованный контракт + `back_office V1` (`docs/lk-contract/v1/openapi.yaml`, HTTP Basic): ЛК шлёт + `POST /api/v1/back_office/claims/` с подписанной (XMLDSig) заявкой, bj шлёт в + НРД и возвращает статус callback-ом `PATCH /api/v1/back_office/claims/{id}`. +- **Автомат:** фирма без Финансы, но с QUIK — свой `bj-lk` + коннектор QUIK МНП + (блокировка позиций хранимыми процедурами). +- **Полуавтомат:** без Финансы и QUIK — свой `bj-lk` + ручные гейты оператора. + +**Интеграции:** НРД ИШ (Интеграционный шлюз, крипто ГОСТ/Валидата); Fancy +(депозитарий/Спектр, MSSQL/PostgreSQL — портфели клиентов, канал обмена и журнал +НРД); QUIK МНП (блокировка, боевая БД — только чтение + штатные процедуры); +ЕСИА Финансы/winr (ЛК). Тестовый контур НРД — TEST3 с роботом (сценарии +1111/2001/2002/3333). + +**Регуляторные факты:** SLA «минуты» (5 мин до 31.08.2027, 2 мин с 01.09.2027); +переводятся только **российские ценные бумаги** (ISIN начинается с RU), +учитываемые на **торговом счёте депо (разделе)**; обособленные бумаги — нельзя. +Отказ по неподходящей бумаге — код НРД M2M01. + +## Capabilities and Constraints + +- Реализован контракт ЕСИА Финансы `back_office V1`: приём подписанной заявки, + проверка подписи (crypto-service), валидация, отправка в НРД, callback со + статусом (`draft/signed/submitted/in_progress/confirmed/rejected/timed_out`) и + кодом причины (`reason_code`/`reason_text`, напр. M2M18). +- Клиентский ЛК `bj-lk` (отдельный бинарь, ставится на веб-сервер брокера): + портфель, подача заявки с подтверждением, статусы; ходит только в bj по API с + ключом, к БД напрямую не подключается. +- Коннектор QUIK МНП (`quikm2m.go`) — регистрация/подпись/снятие поручений; + сейчас за предохранителем, ждёт настройки QAdmin у аутсорсера. +- Чтение реальных портфелей из Fancy (`BJ_DEPO_DATA`); правило RU-only и + человекочитаемые коды отказа M2Mxx. +- **Терминология (не переводить, не упрощать):** M2M, MOEX МОСТ, НРД, ИШ + (Интеграционный шлюз), депозитарий, депонент, счёт депо, раздел, поручение, + контрагент, место расчётов (НРД/СПБ), торговый счёт. +- **Открытые/недорешённые факты:** транспорт depo_m2m у Финансы (V1 back_office + или их webhook-модель) — на подтверждении; типы QUIK 100/101 не включены + (у аутсорсера); `place_of_payment=spb` не реализован; справочник контрагентов + (ИНН) у Финансы ещё не готов. + +## Brand Commitments + +Фиксированный бренд продукта — **Bridge-and-Join-s (bj)**. Клиентский ЛК может +нести название брокера через настройку `LK_TITLE` (напр. «Личный кабинет · +Фонтвьель»), но это конфигурация развёртывания, а не смена бренда продукта. +Язык интерфейса — русский; тон — деловой, финансово-точный. + +## Evidence on Hand + +- Контракт-спецификация: `docs/lk-contract/v1/openapi.yaml`, `DOC/API ЛК ЕСИА.pdf`. +- Документация НРД (`DOC/`), QUIK (`/home/fontvielle/quik-docs/`), Финансы/winr + (`/home/fontvielle/lk-docs/`). +- Реальные данные: боевые базы Fancy (`BJ_DEPO_DATA` — настоящие портфели + клиентов), боевая БД QUIK МНП (`88_INSTR`), тестовый контур НРД с роботом. +- Пройденные проверки: сквозной цикл ЛК → НРД (робот) с реальным клиентом; + замыкание исхода (в т.ч. отказ M2M18) на клиента. +- НЕ выдумывать: отзывы, цены, лицензии, метрики, клиентов — таких данных нет. + +## Product Principles + +1. **bj — шлюз в НРД, а не полный стек.** Интегрироваться с ЛК/QUIK/Fancy, а не + дублировать их. Сначала проверять существующий контракт, потом строить. +2. **Безопасность необратимых операций.** Перевод бумаг необратим: подтверждение + перед подачей, никаких необеспеченных поручений в НРД. +3. **Боевые системы — только на чтение.** Fancy и QUIK боевые; писать только + штатными процедурами API, никаких прямых INSERT/UPDATE в их таблицы. +4. **Понятный исход.** Коды НРД (M2Mxx) и статусы показывать человекочитаемо — + и оператору, и клиенту; статус должен честно отражать реальность (отказ ≠ + «в работе», «отправлено» ≠ «исполнено»). +5. **Регуляторная корректность.** Только российские бумаги на торговом счёте, SLA + «минуты», соответствие требованиям Банка России. + +## Accessibility & Inclusion + +Обычная веб-доступность уровня **WCAG AA**: достаточный контраст (в т.ч. тёмная +тема), программные `label` у полей, доступность с клавиатуры и для скринридеров, +адекватные тап-таргеты. Отдельных регуляторных требований к доступности пока не +установлено. diff --git a/cmd/bj-lk/main.go b/cmd/bj-lk/main.go new file mode 100644 index 0000000..da8664f --- /dev/null +++ b/cmd/bj-lk/main.go @@ -0,0 +1,447 @@ +// Команда bj-lk — Личный кабинет клиента (отдельный модуль). +// +// Ставится на любой веб-сервер клиента. Сам к базам данных НЕ подключается — +// работает только с bj-server по HTTP API с ключом доступа. Клиент входит по +// логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру. +// +// Настройка — через переменные окружения или флаги: +// +// BJ_URL адрес bj-server (напр. https://bj.example.ru) +// BJ_KEY ключ доступа модуля (выдаётся в админке bj-server) +// LISTEN адрес прослушивания (по умолчанию :8081) +// LK_TITLE название кабинета в шапке (по умолчанию «Личный кабинет») +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на +// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и +// клиент увидит «сессия истекла». Кука продлевается при каждом запросе. +const cookieTTL = 12 * 3600 + +type config struct { + BJURL string + BJKey string + Listen string + Title string +} + +func loadConfig() config { + c := config{ + BJURL: os.Getenv("BJ_URL"), + BJKey: os.Getenv("BJ_KEY"), + Listen: os.Getenv("LISTEN"), + Title: os.Getenv("LK_TITLE"), + } + flag.StringVar(&c.BJURL, "bj-url", c.BJURL, "адрес bj-server (https://...)") + flag.StringVar(&c.BJKey, "bj-key", c.BJKey, "ключ доступа модуля") + flag.StringVar(&c.Listen, "listen", c.Listen, "адрес прослушивания (:8081)") + flag.StringVar(&c.Title, "title", c.Title, "заголовок кабинета") + flag.Parse() + if c.Listen == "" { + c.Listen = ":8081" + } + if c.Title == "" { + c.Title = "Личный кабинет" + } + c.BJURL = strings.TrimRight(c.BJURL, "/") + return c +} + +// ---- клиент к bj-server ---- + +type bjClient struct { + cfg config + hc *http.Client +} + +func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error { + var rdr *bytes.Reader + if body != nil { + raw, _ := json.Marshal(body) + rdr = bytes.NewReader(raw) + } else { + rdr = bytes.NewReader(nil) + } + req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr) + if err != nil { + return err + } + req.Header.Set("X-BJ-Key", b.cfg.BJKey) + if session != "" { + req.Header.Set("X-BJ-Session", session) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := b.hc.Do(req) + if err != nil { + return fmt.Errorf("нет связи с сервером: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + var e struct{ Error string } + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error == "" { + e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode) + } + return fmt.Errorf("%s", e.Error) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// ---- модели ответов ---- + +type position struct { + ISIN string `json:"isin"` + SecurityCode string `json:"security_code"` + SecurityName string `json:"security_name"` + Quantity float64 `json:"quantity"` + Blocked float64 `json:"blocked"` + Available float64 `json:"available"` + UpdatedAt string `json:"updated_at"` + Transferable bool `json:"transferable"` + Note string `json:"note"` +} + +type claimRow struct { + ID int64 `json:"id"` + GUID string `json:"guid"` + StatusRu string `json:"status_ru"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Securit int `json:"securities"` + Reason string `json:"reason"` +} + +func main() { + cfg := loadConfig() + if cfg.BJURL == "" || cfg.BJKey == "" { + log.Fatal("не заданы BJ_URL и BJ_KEY — укажите адрес bj-server и ключ доступа (см. инструкцию)") + } + bj := &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}} + tpl := template.Must(template.New("t").Funcs(template.FuncMap{ + "fmtnum": func(f float64) string { + if f == float64(int64(f)) { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) + }, + "shortid": func(s string) string { + if len(s) > 8 { + return s[:8] + "…" + } + return s + }, + }).Parse(pageTemplates)) + + mux := http.NewServeMux() + // признаки режима (админ / просмотр кабинета клиента) — из кук + var curReq *http.Request + render := func(w http.ResponseWriter, name string, data map[string]any) { + if data == nil { + data = map[string]any{} + } + data["Title"] = cfg.Title + if _, ok := data["Nav"]; !ok { + data["Nav"] = "" + } + if curReq != nil { + if _, ok := data["IsAdmin"]; !ok { + if c, err := curReq.Cookie("bj_lk_admin"); err == nil && c.Value == "1" { + data["IsAdmin"] = true + } + } + if c, err := curReq.Cookie("bj_lk_as"); err == nil && c.Value != "" { + data["AsClient"] = c.Value + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tpl.ExecuteTemplate(w, name, data); err != nil { + log.Printf("шаблон %s: %v", name, err) + } + } + setFlag := func(w http.ResponseWriter, name, val string) { + age := cookieTTL + if val == "" { + age = -1 + } + http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode}) + } + // sessionW — как session, но заодно продлевает куку, чтобы у активного + // клиента кабинет не сбрасывался на середине работы. + sessionW := func(w http.ResponseWriter, r *http.Request) string { + curReq = r + c, err := r.Cookie("bj_lk") + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + return c.Value + } + session := func(r *http.Request) string { + curReq = r + c, err := r.Cookie("bj_lk") + if err != nil { + return "" + } + return c.Value + } + + // Вход + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + render(w, "login", map[string]any{"Flash": r.URL.Query().Get("e")}) + return + } + _ = r.ParseForm() + var out struct { + Session string `json:"session"` + FullName string `json:"full_name"` + Contract string `json:"contract"` + IsAdmin bool `json:"is_admin"` + } + err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{ + "login": r.FormValue("login"), "password": r.FormValue("password"), + }, &out) + if err != nil { + render(w, "login", map[string]any{"Flash": err.Error()}) + return + } + http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + if out.IsAdmin { + setFlag(w, "bj_lk_admin", "1") + http.Redirect(w, r, "/clients", http.StatusSeeOther) + return + } + setFlag(w, "bj_lk_admin", "") + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + + mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { + _ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil) + http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) + + // Портфель + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + s := sessionW(w, r) + if s == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + var out struct { + Contract string `json:"contract"` + FullName string `json:"full_name"` + Positions []position `json:"positions"` + Source string `json:"source"` + ReadOnly bool `json:"read_only"` + } + if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil { + render(w, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()}) + return + } + render(w, "portfolio", map[string]any{ + "Nav": "p", "Contract": out.Contract, "FullName": out.FullName, + "Positions": out.Positions, "Flash": r.URL.Query().Get("m"), + "Source": out.Source, "ReadOnly": out.ReadOnly, + }) + }) + + // Заявка на перевод + mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) { + s := sessionW(w, r) + if s == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if r.Method == http.MethodGet { + var out struct { + Positions []position `json:"positions"` + FullName string `json:"full_name"` + } + if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", s, nil, &out); err != nil { + render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()}) + return + } + render(w, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName}) + return + } + _ = r.ParseForm() + var secs []map[string]any + for _, isin := range r.Form["isin"] { + q := strings.TrimSpace(r.FormValue("qty_" + isin)) + if q == "" || q == "0" { + continue + } + f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64) + if err != nil || f <= 0 { + continue + } + secs = append(secs, map[string]any{"isin": isin, "quantity": f}) + } + if len(secs) == 0 { + render(w, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"}) + return + } + var res struct { + Message string `json:"message"` + GUID string `json:"guid"` + } + err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", s, map[string]any{ + "receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")), + "receiving_name": strings.TrimSpace(r.FormValue("receiving_name")), + "account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")), + "comment": strings.TrimSpace(r.FormValue("comment")), + "securities": secs, + }, &res) + if err != nil { + render(w, "transfer", map[string]any{"Nav": "t", "Error": err.Error()}) + return + } + http.Redirect(w, r, "/claims?m="+res.Message, http.StatusSeeOther) + }) + + // Мои заявки + mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) { + s := sessionW(w, r) + if s == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + var out struct { + Claims []claimRow `json:"claims"` + } + if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", s, nil, &out); err != nil { + render(w, "claims", map[string]any{"Nav": "c", "Error": err.Error()}) + return + } + render(w, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")}) + }) + + // Администратор: список клиентов + mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) { + s := sessionW(w, r) + if s == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + var out struct { + Clients []struct { + Login string `json:"login"` + Contract string `json:"contract_number"` + FullName string `json:"full_name"` + Email string `json:"email"` + Active bool `json:"active"` + IsAdmin bool `json:"is_admin"` + } `json:"clients"` + Depo []struct { + Contract string `json:"contract"` + Name string `json:"name"` + Securities int `json:"securities"` + } `json:"depo"` + DepoError string `json:"depo_error"` + } + search := strings.TrimSpace(r.URL.Query().Get("search")) + path := "/api/lk/admin/clients" + if search != "" { + path += "?search=" + url.QueryEscape(search) + } + if err := bj.call(r.Context(), http.MethodGet, path, s, nil, &out); err != nil { + render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()}) + return + } + render(w, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients, + "Depo": out.Depo, "DepoError": out.DepoError, "Search": search, + "Flash": r.URL.Query().Get("m")}) + }) + + // Администратор: войти в кабинет клиента + mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) { + s := sessionW(w, r) + if s == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + login := r.FormValue("login") + if login == "" { + login = r.URL.Query().Get("login") + } + contract := r.FormValue("contract") + if contract == "" { + contract = r.URL.Query().Get("contract") + } + var out struct { + Session string `json:"session"` + AsClient string `json:"as_client"` + } + if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", s, + map[string]string{"login": login, "contract": contract}, &out); err != nil { + http.Redirect(w, r, "/clients?m="+err.Error(), http.StatusSeeOther) + return + } + // сохраняем админскую сессию, чтобы можно было вернуться + if c, err := r.Cookie("bj_lk"); err == nil { + setFlag(w, "bj_lk_admin_sess", c.Value) + } + http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: out.Session, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + setFlag(w, "bj_lk_as", out.AsClient) + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + + // Администратор: вернуться из кабинета клиента + mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { + http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: c.Value, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL}) + } + setFlag(w, "bj_lk_as", "") + setFlag(w, "bj_lk_admin_sess", "") + http.Redirect(w, r, "/clients", http.StatusSeeOther) + }) + + // Проверка связи с bj-server (для админа при установке) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + var out map[string]any + err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err != nil { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": cfg.BJURL}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": cfg.BJURL}) + }) + + log.Printf("bj-lk (Личный кабинет) слушает %s, bj-server: %s", cfg.Listen, cfg.BJURL) + srv := &http.Server{Addr: cfg.Listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + _ = context.Background +} diff --git a/cmd/bj-lk/templates.go b/cmd/bj-lk/templates.go new file mode 100644 index 0000000..00e33e2 --- /dev/null +++ b/cmd/bj-lk/templates.go @@ -0,0 +1,311 @@ +package main + +// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей, +// чтобы модуль был одним файлом и ставился на любой веб-сервер). +const pageTemplates = ` +{{define "head"}} + +{{.Title}} +{{end}} + +{{define "nav"}}

    {{.Title}} +{{if .IsAdmin}}Клиенты{{end}} +Мои бумаги +Перевести бумаги +Мои заявки +{{with .FullName}}{{.}}{{end}}{{with .Contract}} · договор {{.}}{{end}} Выйти +
    +{{if .AsClient}}
    + 👤 Вы просматриваете кабинет клиента {{.AsClient}} от имени администратора · + вернуться к списку клиентов
    {{end}}{{end}} + +{{define "login"}}{{template "head" .}} + +{{end}} + +{{define "portfolio"}}{{template "head" .}}{{template "nav" .}} +
    +

    Мои ценные бумаги

    + {{if .Source}}

    Источник данных: {{.Source}}{{if .Contract}} · договор {{.Contract}}{{end}}

    {{end}} + {{with .Flash}}
    {{.}}
    {{end}} + {{with .Error}}
    {{.}}
    {{end}} +
    + {{if .Positions}} +
    + + + {{range .Positions}} + + + + + + + + {{end}} + +
    БумагаISINВсегоЗаблокированоДоступно к переводу
    {{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}{{.ISIN}}{{fmtnum .Quantity}}{{if .Blocked}}{{fmtnum .Blocked}}{{else}}—{{end}}{{if .Transferable}}{{fmtnum .Available}}{{else}}не переводится{{end}}
    +

    Через сервис MOEX МОСТ переводятся только российские ценные бумаги (ISIN начинается с RU). Иностранные бумаги показаны для полноты портфеля, но заявку по ним подать нельзя.

    + {{if .ReadOnly}} +

    📋 Данные загружены из депозитарного учёта. Подача заявок на перевод для этого договора пока недоступна — раздел работает в режиме просмотра.

    + {{else}} +

    Перевести бумаги другому брокеру

    + {{end}} + {{else}} +

    Бумаг на счёте не найдено. Если вы считаете, что это ошибка — обратитесь к вашему брокеру.

    + {{end}} +
    +
    {{end}} + +{{define "transfer"}}{{template "head" .}}{{template "nav" .}} +
    +

    Перевод бумаг другому брокеру

    + {{with .Error}}
    {{.}}
    {{end}} +
    +
    +

    Куда переводим

    +

    Укажите депозитарий брокера, к которому переводите бумаги. Перевод оформляется на вас же — как перевод «самому себе» в другой депозитарий.

    +
    +
    + +

    ИНН депозитария вашего нового брокера — уточните его у брокера-получателя.

    +
    +
    +
    +
    +
    +

    Что переводим

    + {{if .Positions}} +
    + + + {{range .Positions}} + + + + + + + {{end}} + +
    БумагаISINДоступноСколько перевести
    {{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}{{.ISIN}}{{if .Transferable}}{{fmtnum .Available}}{{else}}—{{end}}{{if .Transferable}} +
    + + +
    + {{else}}{{.Note}}{{end}}
    +
    +
    + + Отмена +
    +

    Перед подачей вы увидите сводку для проверки. После подачи заявка проходит проверку у брокера, затем направляется в НРД. Статус виден в разделе «Мои заявки».

    + {{else}}

    Нет доступных бумаг для перевода.

    {{end}} +
    +
    + + + + +
    {{end}} + +{{define "claims"}}{{template "head" .}}{{template "nav" .}} +
    +

    Мои заявки

    + {{with .Flash}}
    {{.}}
    {{end}} + {{with .Error}}
    {{.}}
    {{end}} +
    + {{if .Claims}} +
    + + + {{range .Claims}} + + + + + + + + {{end}} + +
    ДатаНомерБумагСтатусПримечание
    {{.CreatedAt}}{{shortid .GUID}}{{.Securit}}{{if or (eq .Status "rejected") (eq .Status "declined") (eq .Status "error")}}{{.StatusRu}} + {{else if eq .Status "done"}}{{.StatusRu}} + {{else}}{{.StatusRu}}{{end}}{{.Reason}}
    +

    Наведите на номер, чтобы увидеть полный идентификатор заявки. Он понадобится при обращении в поддержку.

    + {{else}} +

    Заявок пока нет. Подать заявку на перевод

    + {{end}} +
    +
    {{end}} + +{{define "clients"}}{{template "head" .}}{{template "nav" .}} +
    +

    Клиенты

    +

    Список клиентов личного кабинета. Можно открыть кабинет любого клиента, чтобы посмотреть его бумаги и заявки его глазами.

    + {{with .Flash}}
    {{.}}
    {{end}} + {{with .Error}}
    {{.}}
    {{end}} +
    + {{if .Clients}} +
    + + + {{range .Clients}} + + + + + + + + + {{end}} + +
    КлиентЛогинДоговорE-mailСтатус
    {{if .FullName}}{{.FullName}}{{else}}—{{end}}{{if .IsAdmin}} админ{{end}}{{.Login}}{{.Contract}}{{.Email}}{{if .Active}}активен{{else}}заблокирован{{end}}{{if not .IsAdmin}}Открыть кабинет{{end}}
    + {{else if not .Error}} +

    Клиентов пока нет.

    + {{end}} +
    + +

    Клиенты депозитария

    +

    Реальные договоры из депозитарного учёта. Учётная запись в кабинете им не нужна — + администратор открывает кабинет по договору в режиме просмотра.

    + {{with .DepoError}}
    Депозитарий недоступен: {{.}}
    {{end}} +
    +
    + + + {{if .Search}}Сбросить{{end}} +
    + {{if .Depo}} +
    + + + {{range .Depo}} + + + + + + + {{end}} + +
    КлиентДоговорБумаг
    {{if .Name}}{{.Name}}{{else}}—{{end}}{{.Contract}}{{.Securities}}Открыть кабинет
    +

    Показаны первые 100 договоров с ненулевыми остатками. Уточните поиск, чтобы найти нужного клиента.

    + {{else if not .DepoError}} +

    По запросу «{{.Search}}» договоров с бумагами не найдено.

    + {{end}} +
    +
    {{end}} +` diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 5a8f239..171c5d5 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -1,10 +1,13 @@ package lkgateway import ( + "context" "embed" + "encoding/json" "fmt" "html/template" "net/http" + "os" "path" "strings" "time" @@ -19,21 +22,134 @@ var templatesFS embed.FS // конкретный content-шаблон). Так html/template не путается с несколькими // {{define "content"}} в разных файлах. type admin struct { - home, claims, claim, status, setup *template.Template - help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template - wizard, news, keyWizard *template.Template + home, claims, claim, status, setup *template.Template + help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template + incoming, incomingDetail, fancyQueue, quikInstr *template.Template + wizard, news, keyWizard *template.Template + handbook *template.Template + m2mtest, m2mpreview, depoSim *template.Template + journal *template.Template } // templateFuncs — функции, доступные внутри шаблонов. Главная задача — // русификация статусов и других технических обозначений (см. требование // «всё UI на русском, кроме программных терминов»). var templateFuncs = template.FuncMap{ - "ru": russianText, - "ruState": russianState, - "ruOutcome": russianOutcome, - "now": time.Now, + "ru": russianText, + "ruState": russianState, + "ruOutcome": russianOutcome, + "now": time.Now, "add": func(a, b int) int { return a + b }, "fallbackTpl": fallback, + // logoSrc — data-URI логотипа отчётов: загруженный из настроек или встроенный. + "logoSrc": func(uri string) template.URL { + if strings.TrimSpace(uri) == "" { + return template.URL(fontvielleLogoDataURI) + } + return template.URL(uri) + }, + // m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)». + "m2mCode": m2mCodeLabel, + "quikCodeMap": quikClientCodeMapText, + "nprF": nprF, + "curUser": authTemplateFuncs["curUser"], + "can": authTemplateFuncs["can"], + "authOpen": authOpen, + "roleTitle": roleTitle, + "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, + "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, + "deref": func(v *float64) float64 { + if v == nil { + return 0 + } + return *v + }, + "list": func(v ...string) []string { return v }, + "has": func(l []string, v string) bool { + for _, x := range l { + if x == v { + return true + } + } + return false + }, + "dict": func(kv ...string) map[string]string { + m := map[string]string{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return m + }, + // inStatus — статус входящего перевода (класс/текст/исход принято-отказано). + "inStatus": incomingStatusOf, + // healthLabel — человекочитаемое имя фонового компонента для панели здоровья. + "healthLabel": HealthLabel, + // dur — округление длительности до секунд для вывода возраста отметки. + "dur": func(d time.Duration) string { return d.Round(time.Second).String() }, + // rejectOptions — варианты кодов отказа для формы решения по входящим. + "rejectOptions": m2mRejectOptions, + // m2mHints — JSON подсказок по кейсам (Зачисляющий) для «Входящих». + "m2mHints": m2mIncomingHintsJSON, + // m2mParty — наименование организации по коду/ИНН из справочника M2M. + "m2mParty": m2mPartyName, + // m2mHandbook — JSON участников справочника для пред-заполнения контрагента. + "m2mHandbook": m2mHandbookJSON, + // xmlFields — таблица со всеми полями XML (для карточки и отчётов). + "xmlFields": xmlFieldsHTML, + // docType — наименование типа документа по коду НРД (частые + fallback). + "docType": func(code string) string { + names := map[string]string{ + "21": "Паспорт гражданина РФ", "10": "Паспорт иностранного гражданина", + "12": "Вид на жительство", "07": "Военный билет", "03": "Свидетельство о рождении", + } + if n := names[strings.TrimSpace(code)]; n != "" { + return n + " (" + code + ")" + } + if strings.TrimSpace(code) == "" { + return "" + } + return "код " + code + }, + // msk — время в московской зоне (UTC+3). Данные хранятся в UTC. + "wall": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.Format("02.01.2006 15:04:05") + }, + "msk": func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + }, + // transit — длительность между двумя моментами (для «времени прохождения»). + "transit": func(a, b time.Time) string { + if a.IsZero() || b.IsZero() { + return "—" + } + d := b.Sub(a) + if d < 0 { + d = -d + } + return d.Round(time.Second).String() + }, + // initial — первая буква строки + "." (rune-safe; пусто для пустой строки). + // Без него {{slice .FirstName 0 1}} падал на пустом имени и портил кириллицу. + "initial": func(s string) string { + for _, r := range s { + return string(r) + "." + } + return "" + }, + // short — первые n рун строки без паники на короткой строке. + "short": func(n int, s string) string { + rs := []rune(s) + if len(rs) <= n { + return s + } + return string(rs[:n]) + }, "anyKeymedia": func(ds []flashDrive) bool { for _, d := range ds { if d.IsKeymedia { @@ -68,6 +184,10 @@ func russianState(s string) string { return "Таймаут SLA" case "manual_approval": return "На ручном разборе" + case "pending": + return "Ждёт решения" + case "answered": + return "Отвечено" } return s } @@ -149,15 +269,80 @@ func newAdmin() (*admin, error) { if err != nil { return nil, fmt.Errorf("parse admin_help_architecture: %w", err) } + helpQuik, err := parse("admin_help_quik.html") + if err != nil { + return nil, fmt.Errorf("parse admin_help_quik: %w", err) + } + helpFancyEvents, err := parse("admin_help_fancy_events.html") + if err != nil { + return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err) + } + parseNPR := func(content string) (*template.Template, error) { + return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content) + } + nprT, err := parseNPR("admin_npr.html") + if err != nil { + return nil, fmt.Errorf("parse admin_npr: %w", err) + } + nprC, err := parseNPR("admin_npr_client.html") + if err != nil { + return nil, fmt.Errorf("parse admin_npr_client: %w", err) + } + nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html") + if err != nil { + return nil, fmt.Errorf("parse admin_npr_rows: %w", err) + } keyWizard, err := parse("admin_keywizard.html") if err != nil { return nil, fmt.Errorf("parse admin_keywizard: %w", err) } + incoming, err := parse("admin_incoming.html") + if err != nil { + return nil, fmt.Errorf("parse admin_incoming: %w", err) + } + incomingDetail, err := parse("admin_incoming_detail.html") + if err != nil { + return nil, fmt.Errorf("parse admin_incoming_detail: %w", err) + } + fancyQueue, err := parse("admin_fancy_queue.html") + if err != nil { + return nil, fmt.Errorf("parse admin_fancy_queue: %w", err) + } + quikInstr, err := parse("admin_quik_instructions.html") + if err != nil { + return nil, fmt.Errorf("parse admin_quik_instructions: %w", err) + } + handbook, err := parse("admin_handbook.html") + if err != nil { + return nil, fmt.Errorf("parse admin_handbook: %w", err) + } + m2mtest, err := parse("admin_m2mtest.html") + if err != nil { + return nil, fmt.Errorf("parse admin_m2mtest: %w", err) + } + m2mpreview, err := parse("admin_m2mpreview.html") + if err != nil { + return nil, fmt.Errorf("parse admin_m2mpreview: %w", err) + } + depoSim, err := parse("admin_depo_sim.html") + if err != nil { + return nil, fmt.Errorf("parse admin_depo_sim: %w", err) + } + journal, err := parse("admin_journal.html") + if err != nil { + return nil, fmt.Errorf("parse admin_journal: %w", err) + } return &admin{ home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys, - helpRobot: helpRobot, helpArchitecture: helpArch, + helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR, wizard: wizard, news: news, keyWizard: keyWizard, + incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr, + handbook: handbook, + m2mtest: m2mtest, + m2mpreview: m2mpreview, + depoSim: depoSim, + journal: journal, }, nil } @@ -168,6 +353,7 @@ type page struct { Now string IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции MockReason string // короткое описание почему mock + NPR bool // модуль «НПР / УДС» включён и разрешён лицензией } // globalRC — ссылка на runtime-конфиг для template-funcs/page helpers. @@ -192,6 +378,9 @@ type homeData struct { AllReady bool NotReadyCount int TotalCount int + + // LKUrl — адрес тестового ЛК (для первичного теста интеграции). + LKUrl string } // claimsData — данные журнала. @@ -204,6 +393,11 @@ type claimsData struct { type claimData struct { page Claim ClaimView + Quik []*QuikLink + Watch *OrderWatch + DepoL *DepoLink + Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл + Flash string } // statusData — данные страницы статуса. @@ -230,11 +424,26 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts a.renderHome(w, r, svc, rc, getOpts()) case p == "claims": a.renderClaims(w, r, svc) + case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/request.xml"): + id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/request.xml") + a.downloadClaimXML(w, r, svc, id, "request") + case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/response.xml"): + id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/response.xml") + a.downloadClaimXML(w, r, svc, id, "response") + case p == "claims-export": + a.exportClientClaims(w, r, svc) + case p == "claims-compare": + a.exportCompare(w, r, svc) + case strings.HasPrefix(p, "claims/") && strings.HasSuffix(p, "/export.zip"): + id := strings.TrimSuffix(strings.TrimPrefix(p, "claims/"), "/export.zip") + a.exportClaim(w, r, svc, id) case strings.HasPrefix(p, "claims/"): id := strings.TrimPrefix(p, "claims/") a.renderClaim(w, r, svc, id) case p == "status": a.renderStatus(w, r, getOpts()) + case p == "handbook": + a.renderHandbook(w, r) case p == "help": render(w, a.help, nowPage("Инструкции", "help")) case p == "help/database": @@ -249,10 +458,17 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts render(w, a.helpRobot, nowPage("Тестирование с роботом", "help")) case p == "help/architecture": render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help")) + case p == "help/fancy-events": + render(w, a.helpFancyEvents, nowPage("bj ↔ bj2depo: события", "help")) + case p == "help/quik": + render(w, a.helpQuik, nowPage("QUIK МНП для M2M", "help")) default: http.NotFound(w, r) } }) + // Документы вендоров (ARQA и др.) для внутренних инструкций: /var/lib/bj/docs/<файл>. + mux.Handle("/admin/docs/", http.StripPrefix("/admin/docs/", http.FileServer(http.Dir("/var/lib/bj/docs")))) + mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/", http.StatusSeeOther) }) @@ -272,6 +488,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, Status: status, Recent: recent.Items, News: topNews(rc.Snapshot().News.Items, 3), + LKUrl: rc.Snapshot().LK.PublicURL, } // Готовность системы считаем ТОЛЬКО по обязательным компонентам. // Опциональные (напр. callback в ЛК) не влияют на «готовность». @@ -318,7 +535,160 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service http.Error(w, err.Error(), http.StatusNotFound) return } - render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view}) + view.Timeline = buildClaimTimeline(r.Context(), view) + var depo *OutgoingDeal + if view.M2MGUID != "" && globalOutgoingStore != nil { + if d, ok := scanForm16Deal(globalOutgoingStore, string(view.M2MGUID)); ok { + depo = d + } + } + var quik []*QuikLink + if view.M2MGUID != "" { + quik = globalQuikLinks.ForGUID(string(view.M2MGUID)) + } + var watch *OrderWatch + if view.M2MGUID != "" { + watch = globalOrderWatch.Get(string(view.M2MGUID)) + } + var depoL *DepoLink + if view.M2MGUID != "" { + depoL = globalDepoLinks.Get(string(view.M2MGUID)) + } + render(w, a.claim, claimData{ + Quik: quik, Watch: watch, DepoL: depoL, + page: nowPage("Заявка", "claims"), + Claim: view, Depo: depo, Flash: r.URL.Query().Get("flash"), + }) +} + +// buildClaimTimeline собирает путь заявки во времени: положена в БД Fancy → +// вердикт «крана» → отправлена в шлюз НРД → ответ НРД. Метки из БД Fancy (по +// GUID) и из самой заявки (Deal). Для прямых заявок (не через базу) — короче. +func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent { + // MSSQL отдаёт метки в UTC, Postgres-сделка — в МСК. Приводим ВСЕ к МСК по + // абсолютному времени, чтобы шаги шли последовательно (дельты на абсолюте + // не меняются). + msk := time.FixedZone("МСК", 3*60*60) + var tl []TimelineEvent + add := func(title, kind, detail string, t time.Time, prev time.Time) { + ev := TimelineEvent{Title: title, Kind: kind, Detail: detail, Time: t.In(msk)} + if !prev.IsZero() && !t.IsZero() && t.After(prev) { + ev.Since = formatSince(t.Sub(prev)) + } + tl = append(tl, ev) + } + + // Заявка ЛК, прошедшая шлюз bj (lkflow.go): путь целиком из сохранённых шагов — + // проверка шлюза, Fancy, имитатор НРД, статус в ЛК. + if v.M2MGUID != "" { + if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 { + var prev time.Time + for _, st := range f.Steps { + add(st.Title, st.Kind, st.Detail, st.At, prev) + prev = st.At + } + if f.Stage == "depo_pending" { + add("Ждём ответ Fancy (DEPO)", "db", "Статус в ЛК будет поставлен после обработки отказа джобой bj2depo.", time.Time{}, time.Time{}) + } + return tl + } + } + + // Происхождение из ЛК ЕСИА Финанс (путь напрямую через коннектор, минуя + // Fancy): показываем стартовый шаг с номером заявки в ЛК. + if v.M2MGUID != "" { + if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok { + add("Заявка из ЛК ЕСИА Финанс", "db", + fmt.Sprintf("Забрана коннектором из личного кабинета (заявка ЛК #%d).", lkID), + v.CreatedAt, time.Time{}) + } + } + + // Отправлена в шлюз НРД (Deal создаётся в момент отправки). + add("Отправлена в интеграционный шлюз НРД", "gateway", "Подписана ключом и передана в ИШ.", v.CreatedAt, time.Time{}) + + // Ответ НРД (если получен). + if v.M2MDecision != nil { + conf, rej := 0, 0 + for _, s := range v.M2MDecision.Securities { + if s.Outcome == "confirmed" { + conf++ + } else { + rej++ + } + } + add("Получено решение НРД (робот)", "response", + fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt) + } else if v.M2MResponse != nil { + var codes string + for _, e := range v.M2MResponse.Responses { + if e.Code != "" { + codes += e.Code + " " + } + } + add("Ответ сервиса МОСТ (НРД)", "response", + "Статус: "+v.M2MResponse.StatusCode+". "+strings.TrimSpace(codes), v.UpdatedAt, v.CreatedAt) + } + return tl +} + +// formatSince — человекочитаемая длительность между шагами таймлайна. +func formatSince(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%dс", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dм %dс", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dч %dм", int(d.Hours()), int(d.Minutes())%60) +} + +// downloadClaimXML отдаёт файл XML (отправленный запрос или ответ НРД) в +// исходной кодировке windows-1251 — чтобы сохранить и переслать в поддержку НРД. +func (a *admin) downloadClaimXML(w http.ResponseWriter, r *http.Request, svc *Service, id, kind string) { + id = path.Base(id) + body, err := svc.ClaimRawXML(r.Context(), id, kind) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + short := id + if len(short) > 8 { + short = short[:8] + } + name := fmt.Sprintf("M2M-%s-%s.xml", kind, short) + w.Header().Set("Content-Type", "application/xml; charset=windows-1251") + w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`) + _, _ = w.Write(body) +} + +// handbookData — данные страницы «Справочник участников M2M». +type handbookData struct { + page + At string + Participants []HandbookEntry + CodesHTML template.HTML // справка по кодам ошибок/отказов M2M +} + +// renderHandbook показывает сохранённый справочник участников M2M с поиском. +// Места расчётов добавляются к участникам как строки с типом "place". +func (a *admin) renderHandbook(w http.ResponseWriter, _ *http.Request) { + data := handbookData{page: nowPage("Справочник участников", "handbook")} + data.CodesHTML = template.HTML(m2mSpravkaHTML(false)) // справка по кодам ошибок + if raw, err := os.ReadFile(handbookFile); err == nil { + var hb struct { + At string `json:"at"` + Participants []HandbookEntry `json:"participants"` + Places []HandbookEntry `json:"settlement_places"` + } + if json.Unmarshal(raw, &hb) == nil { + if t, e := time.Parse(time.RFC3339, hb.At); e == nil { + data.At = t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04") + } + data.Participants = append(hb.Participants, hb.Places...) + } + } + render(w, a.handbook, data) } func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) { @@ -330,7 +700,10 @@ func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckO func render(w http.ResponseWriter, t *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := t.ExecuteTemplate(w, "layout", data); err != nil { + // Админка динамическая — никогда не кэшируем HTML, иначе браузер отдаёт + // устаревшую страницу (старые формы/скрипты после обновления bj-server). + w.Header().Set("Cache-Control", "no-store, must-revalidate") + if err := renderFor(w, t, data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } @@ -338,6 +711,7 @@ func render(w http.ResponseWriter, t *template.Template, data any) { func nowPage(title, active string) page { p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} if globalRC != nil { + p.NPR = nprAvailable(globalRC) s := globalRC.Snapshot() switch { case s.NSD.IGWBaseURL == "": diff --git a/internal/lkgateway/adrecheck.go b/internal/lkgateway/adrecheck.go new file mode 100644 index 0000000..95eba71 --- /dev/null +++ b/internal/lkgateway/adrecheck.go @@ -0,0 +1,227 @@ +package lkgateway + +// Перепроверка групп AD. Роль доменного пользователя назначается при входе +// (adAuthenticate), а этот цикл раз в N минут (по умолчанию 15) перечитывает +// его группы от имени сервисной учётной записи: сменилась группа — меняется роль, +// удалён из групп, отключён или удалён в AD — доступ снимается и сессии +// завершаются. Пароли пользователей bj не хранит, поэтому без сервисной +// учётной записи перепроверка не выполняется. + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" +) + +const adRecheckDefault = 15 * time.Minute + +var ( + errADNotFound = errors.New("пользователь не найден в AD") + errADDisabled = errors.New("учётная запись отключена в AD") +) + +func (a AuthSettings) recheckEvery() time.Duration { + switch { + case a.ADRecheckMinutes <= 0: + return adRecheckDefault + case a.ADRecheckMinutes < 5: + return 5 * time.Minute + } + return time.Duration(a.ADRecheckMinutes) * time.Minute +} + +func (a AuthSettings) recheckOn() bool { + return a.ADEnabled && strings.TrimSpace(a.ADBindUser) != "" && a.ADBindPassword != "" +} + +// ADRecheckStatus — итог последней перепроверки (для страницы настроек). +type ADRecheckStatus struct { + At time.Time + Checked int + Changed int + Err string +} + +var ( + adRecheckMu sync.Mutex + adRecheckState ADRecheckStatus +) + +func adRecheckSnapshot() ADRecheckStatus { + adRecheckMu.Lock() + defer adRecheckMu.Unlock() + return adRecheckState +} + +// adSplitLogin — sAMAccountName и UPN из ivanov, ivanov@corp.ru или CORP\ivanov. +func adSplitLogin(a AuthSettings, login string) (sam, upn string) { + login = strings.TrimSpace(login) + sam, upn = login, login + if i := strings.Index(login, "@"); i > 0 { + sam = login[:i] + } else if i := strings.Index(login, `\`); i > 0 { + sam = login[i+1:] + upn = sam + "@" + a.ADDomain + } else if a.ADDomain != "" { + upn = login + "@" + a.ADDomain + } + return sam, upn +} + +func adBindService(conn *ldap.Conn, a AuthSettings) error { + user := strings.TrimSpace(a.ADBindUser) + if !strings.ContainsAny(user, `@\=`) && a.ADDomain != "" { + user += "@" + a.ADDomain + } + if err := conn.Bind(user, a.ADBindPassword); err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { + return fmt.Errorf("сервисная учётная запись %s: неверный логин или пароль", user) + } + return fmt.Errorf("сервисная учётная запись %s: %v", user, err) + } + return nil +} + +// adApplyRecheck применяет итог проверки к пользователю. lookupErr — nil, +// errADNotFound или errADDisabled; roleErr — nil или *adNoGroupError. +// dropSessions — доступ снят, сессии нужно завершить. +func adApplyRecheck(u *User, res *adResult, lookupErr, roleErr error) (changed, dropSessions bool, msg string) { + if lookupErr != nil || roleErr != nil { + reason := "нет в сопоставленных группах" + if lookupErr != nil { + reason = lookupErr.Error() + } + if u.Role == "" { + return false, false, "" // доступ уже снят прошлой проверкой + } + msg = fmt.Sprintf("доступ снят (%s), была роль «%s»", reason, roleTitle(u.Role)) + u.Role = "" + u.Note = "доступ снят по AD: " + reason + return true, true, msg + } + if res.Name != "" { + u.Name = res.Name + } + if res.Email != "" { + u.Email = res.Email + } + u.ADGroups = nil + for _, g := range res.Groups { + u.ADGroups = append(u.ADGroups, dnCN(g)) + } + u.Note = "роль по AD: " + res.MatchedBy + if res.Role == u.Role { + return false, false, "" + } + was := "без роли" + if u.Role != "" { + was = roleTitle(u.Role) + } + msg = fmt.Sprintf("роль «%s» → «%s» (по «%s»)", was, roleTitle(res.Role), res.MatchedBy) + u.Role = res.Role + return true, false, msg +} + +// adRecheckRun — одна перепроверка всех активных доменных пользователей. +func adRecheckRun(a AuthSettings) ADRecheckStatus { + st := ADRecheckStatus{At: time.Now()} + if err := adRecheckUsers(a, &st); err != nil { + st.Err = err.Error() + log.Printf("auth: перепроверка групп AD не выполнена: %v", err) + } + adRecheckMu.Lock() + adRecheckState = st + adRecheckMu.Unlock() + return st +} + +func adRecheckUsers(a AuthSettings, st *ADRecheckStatus) error { + var logins []string + for _, u := range globalUsers.List() { + if u.Source == "ad" && u.Active { + logins = append(logins, u.Login) + } + } + if len(logins) == 0 { + return nil + } + conn, err := adDial(a) + if err != nil { + return err + } + defer conn.Close() + if err := adBindService(conn, a); err != nil { + return err + } + for _, login := range logins { + sam, upn := adSplitLogin(a, login) + res, base, lerr := adLookup(conn, a, sam, upn) + if lerr != nil && lerr != errADNotFound && lerr != errADDisabled { + // сбой поиска — роли не трогаем, иначе обрыв связи снял бы доступ у всех + return fmt.Errorf("%s: %v", login, lerr) + } + var rerr error + if lerr == nil { + if rerr = adResolveRole(conn, a, base, sam, res); rerr != nil { + var ng *adNoGroupError + if !errors.As(rerr, &ng) { + return fmt.Errorf("%s: %v", login, rerr) + } + } + } + st.Checked++ + var changed, drop bool + var msg string + _ = globalUsers.update(login, func(u *User) { changed, drop, msg = adApplyRecheck(u, res, lerr, rerr) }) + if changed { + st.Changed++ + log.Printf("auth: перепроверка AD — %s: %s", login, msg) + } + if drop { + globalSessions.DropUser(login) + } + } + return nil +} + +func (s *Server) adRecheckLoop(ctx context.Context) { + timer := time.NewTimer(time.Minute) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + a := s.rc.Snapshot().Auth + if a.recheckOn() { + adRecheckRun(a) + } + timer.Reset(a.recheckEvery()) + } +} + +func (h *setupHandlers) recheckAD(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Auth + if !a.recheckOn() { + h.usersFlash(w, r, "AD: для перепроверки групп включите вход через AD и укажите сервисную учётную запись с паролем.") + return + } + st := adRecheckRun(a) + if st.Err != "" { + h.usersFlash(w, r, "AD: перепроверка не выполнена — "+st.Err) + return + } + h.usersFlash(w, r, fmt.Sprintf("AD: перепроверено пользователей %d, изменений %d.", st.Checked, st.Changed)) +} diff --git a/internal/lkgateway/adrecheck_test.go b/internal/lkgateway/adrecheck_test.go new file mode 100644 index 0000000..1024a51 --- /dev/null +++ b/internal/lkgateway/adrecheck_test.go @@ -0,0 +1,55 @@ +package lkgateway + +import "testing" + +func TestADApplyRecheck(t *testing.T) { + grp := "CN=BJ-Operators,OU=Groups,DC=corp,DC=example,DC=ru" + + u := &User{Login: "ivanov", Source: "ad", Role: "admin", Active: true} + changed, drop, _ := adApplyRecheck(u, &adResult{Role: "operator", MatchedBy: "BJ-Operators", Groups: []string{grp}}, nil, nil) + if !changed || drop || u.Role != "operator" || len(u.ADGroups) != 1 || u.ADGroups[0] != "BJ-Operators" { + t.Fatalf("смена группы: changed=%v drop=%v role=%q groups=%v", changed, drop, u.Role, u.ADGroups) + } + + changed, drop, _ = adApplyRecheck(u, &adResult{Role: "operator", MatchedBy: "BJ-Operators"}, nil, nil) + if changed || drop { + t.Fatal("роль не изменилась — изменений быть не должно") + } + + changed, drop, _ = adApplyRecheck(u, &adResult{}, nil, &adNoGroupError{sam: "ivanov"}) + if !changed || !drop || u.Role != "" || userHasAccess(u) { + t.Fatalf("удалён из групп: changed=%v drop=%v role=%q", changed, drop, u.Role) + } + changed, drop, _ = adApplyRecheck(u, &adResult{}, nil, &adNoGroupError{sam: "ivanov"}) + if changed || drop { + t.Fatal("повторная проверка без групп не должна повторно снимать доступ") + } + + changed, _, _ = adApplyRecheck(u, &adResult{Role: "viewer", MatchedBy: "BJ-Viewers"}, nil, nil) + if !changed || u.Role != "viewer" { + t.Fatal("возврат в группу должен вернуть роль") + } + + for _, e := range []error{errADDisabled, errADNotFound} { + v := &User{Login: "petrov", Source: "ad", Role: "viewer", Active: true} + if changed, drop, _ := adApplyRecheck(v, nil, e, nil); !changed || !drop || v.Role != "" { + t.Fatalf("%v: доступ должен сниматься", e) + } + } +} + +func TestADSplitLogin(t *testing.T) { + a := AuthSettings{ADDomain: "corp.example.ru"} + for in, want := range map[string][2]string{ + "ivanov": {"ivanov", "ivanov@corp.example.ru"}, + "ivanov@corp.example.ru": {"ivanov", "ivanov@corp.example.ru"}, + `CORP\ivanov`: {"ivanov", "ivanov@corp.example.ru"}, + } { + if sam, upn := adSplitLogin(a, in); sam != want[0] || upn != want[1] { + t.Fatalf("%s → %s, %s", in, sam, upn) + } + } + if (AuthSettings{}).recheckEvery() != adRecheckDefault || (AuthSettings{ADRecheckMinutes: 1}).recheckEvery().Minutes() != 5 { + t.Fatal("интервал перепроверки") + } +} diff --git a/internal/lkgateway/advanced_gate.go b/internal/lkgateway/advanced_gate.go new file mode 100644 index 0000000..9c9f82a --- /dev/null +++ b/internal/lkgateway/advanced_gate.go @@ -0,0 +1,86 @@ +package lkgateway + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "os" +) + +// advanced_gate.go — пароль на «перспективные модули» (QUIK МНП, Обмен Fancy). +// Эти модули относятся к будущим профилям («автомат/полуавтомат» со своим ЛК) и +// пока не в проде. Чтобы оператор их случайно не трогал, они скрыты и +// открываются паролем. Простая cookie-схема: значение cookie = токен пароля, +// подделать без пароля нельзя. Пароль берётся из BJ_ADVANCED_PASSWORD, иначе — +// значение по умолчанию. +const defaultAdvancedPassword = "bj-modules-2026" +const advancedCookie = "bj_adv" + +func effectiveAdvancedPassword() string { + if p := os.Getenv("BJ_ADVANCED_PASSWORD"); p != "" { + return p + } + return defaultAdvancedPassword +} + +// advancedToken — неподделываемый токен для cookie: hex(sha256(пароль)). +func advancedToken() string { + sum := sha256.Sum256([]byte("bj-adv:" + effectiveAdvancedPassword())) + return hex.EncodeToString(sum[:]) +} + +// advancedUnlocked — открыт ли доступ к перспективным модулям в этом запросе. +func advancedUnlocked(r *http.Request) bool { + // Модуль QUIK МНП (обмен Fancy) есть в лицензии — разделы открыты без пароля: пароль нужен + // только там, где модули не куплены. Иначе кнопки разделов видны, а настройки пустые. + if globalRC != nil && moduleAllowed(globalRC, "quik-mnp") { + return true + } + c, err := r.Cookie(advancedCookie) + if err != nil { + return false + } + want := advancedToken() + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1 +} + +// requireAdvanced — защита ручек сохранения перспективных модулей: если доступ +// не открыт, отвечает 403. true — можно продолжать. +func requireAdvanced(w http.ResponseWriter, r *http.Request) bool { + if advancedUnlocked(r) { + return true + } + http.Error(w, "Раздел «Перспективные модули» закрыт. Откройте его паролем в Настройке.", http.StatusForbidden) + return false +} + +// unlockAdvanced — POST /admin/setup/advanced-unlock: проверяет пароль и ставит +// cookie доступа. +func (h *setupHandlers) unlockAdvanced(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if r.FormValue("password") != effectiveAdvancedPassword() { + setupFlash(w, r, "Перспективные модули: неверный пароль") + return + } + http.SetCookie(w, &http.Cookie{ + Name: advancedCookie, Value: advancedToken(), Path: "/admin", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 3600, + }) + setupFlash(w, r, "Перспективные модули открыты (QUIK, Обмен Fancy). Разделы появились в меню слева.") +} + +// lockAdvanced — POST /admin/setup/advanced-lock: снова прячет модули. +func (h *setupHandlers) lockAdvanced(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + http.SetCookie(w, &http.Cookie{ + Name: advancedCookie, Value: "", Path: "/admin", HttpOnly: true, MaxAge: -1, + }) + setupFlash(w, r, "Перспективные модули снова скрыты") +} diff --git a/internal/lkgateway/alerts.go b/internal/lkgateway/alerts.go new file mode 100644 index 0000000..e490aa3 --- /dev/null +++ b/internal/lkgateway/alerts.go @@ -0,0 +1,326 @@ +package lkgateway + +import ( + "context" + "crypto/tls" + "encoding/binary" + "encoding/json" + "fmt" + "log" + "mime" + "net" + "net/smtp" + "strings" + "time" +) + +// alerts.go — внешняя доставка системных оповещений сверх ленты «Новости». +// Единая точка врезки — dispatchAlert, вызывается из raiseSystemAlert. +// Два канала: SMTP (почта оператору) и Zabbix trapper (хук в мониторинг). + +// dispatchAlert рассылает оповещение по включённым внешним каналам. +// Best-effort: ошибки логируются, но НЕ поднимают новый алерт (иначе рекурсия). +func dispatchAlert(rc *RuntimeConfig, title, body string) { + if rc == nil { + return + } + cfg := rc.Snapshot() + a := cfg.Alerts + if a.SMTP.Enabled { + // адрес алертов + группы по маршруту типа (сроки поручений / QUIK / системные) + s := a.SMTP + s.To = mergeEmails(a.SMTP.To, cfg.Incoming.Recipients(alertKind(title))) + if strings.TrimSpace(s.To) != "" { + if err := sendSMTP(s, "[bj] "+title, body); err != nil { + log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err) + } + } + } + if a.Zabbix.Enabled { + if info, err := sendZabbix(a.Zabbix, title+" — "+body); err != nil { + log.Printf("lk-gateway: алерт Zabbix не доставлен: %v", err) + } else { + log.Printf("lk-gateway: алерт Zabbix отправлен: %s", info) + } + } +} + +// --- SMTP --- + +// sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования, +// опциональная аутентификация). Таймаут 15 с. +func sendSMTP(s SMTPSettings, subject, body string) error { + if strings.TrimSpace(s.Host) == "" { + return fmt.Errorf("не задан хост SMTP") + } + if strings.TrimSpace(s.From) == "" { + return fmt.Errorf("не задан отправитель (From)") + } + rcpts := splitRecipients(s.To) + if len(rcpts) == 0 { + return fmt.Errorf("не заданы получатели (To)") + } + port := s.Port + if port == 0 { + port = 587 + } + addr := fmt.Sprintf("%s:%d", s.Host, port) + msg := buildMIME(s.From, rcpts, subject, body) + + dialer := &net.Dialer{Timeout: 15 * time.Second} + var client *smtp.Client + var err error + if strings.EqualFold(s.Security, "ssl") { + // Неявный TLS (обычно порт 465). + conn, e := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: s.Host}) + if e != nil { + return fmt.Errorf("TLS-соединение: %w", e) + } + client, err = smtp.NewClient(conn, s.Host) + } else { + conn, e := dialer.Dial("tcp", addr) + if e != nil { + return fmt.Errorf("соединение: %w", e) + } + client, err = smtp.NewClient(conn, s.Host) + } + if err != nil { + return fmt.Errorf("SMTP-клиент: %w", err) + } + defer client.Close() + + if !strings.EqualFold(s.Security, "ssl") && !strings.EqualFold(s.Security, "none") { + // STARTTLS (по умолчанию, обычно порт 587). + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { + return fmt.Errorf("STARTTLS: %w", err) + } + } else { + return fmt.Errorf("сервер не поддерживает STARTTLS — выберите «без шифрования» или «SSL»") + } + } + if strings.TrimSpace(s.Username) != "" { + if err := smtpAuthenticate(client, s.Username, s.Password, s.Host); err != nil { + return err + } + } + if err := client.Mail(s.From); err != nil { + return fmt.Errorf("MAIL FROM: %w", err) + } + for _, r := range rcpts { + if err := client.Rcpt(r); err != nil { + return fmt.Errorf("RCPT TO %s: %w", r, err) + } + } + wc, err := client.Data() + if err != nil { + return fmt.Errorf("DATA: %w", err) + } + if _, err := wc.Write(msg); err != nil { + return fmt.Errorf("запись тела: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("завершение тела: %w", err) + } + return client.Quit() +} + +// smtpAuthenticate выбирает механизм по объявленным сервером (PLAIN/LOGIN/ +// CRAM-MD5) и пробует их по очереди — многие серверы не поддерживают PLAIN +// (ошибка «504 Unrecognized authentication type»), а только LOGIN. +func smtpAuthenticate(client *smtp.Client, user, pass, host string) error { + ok, mechs := client.Extension("AUTH") + if !ok { + return fmt.Errorf("сервер не поддерживает аутентификацию (AUTH), но задан логин") + } + var tried []smtp.Auth + if strings.Contains(mechs, "PLAIN") { + tried = append(tried, smtp.PlainAuth("", user, pass, host)) + } + if strings.Contains(mechs, "LOGIN") { + tried = append(tried, loginAuth(user, pass)) + } + if strings.Contains(mechs, "CRAM-MD5") { + tried = append(tried, smtp.CRAMMD5Auth(user, pass)) + } + if len(tried) == 0 { + return fmt.Errorf("сервер предлагает механизмы аутентификации, не поддерживаемые bj: [%s]", mechs) + } + var lastErr error + for _, a := range tried { + if err := client.Auth(a); err == nil { + return nil + } else { + lastErr = err + } + } + return fmt.Errorf("аутентификация не удалась (механизмы сервера: [%s]): %w", mechs, lastErr) +} + +// loginAuth — механизм AUTH LOGIN (не входит в stdlib). Сервер шлёт запросы +// «Username:»/«Password:», отвечаем логином и паролем. +type loginAuthenticator struct{ user, pass string } + +func loginAuth(user, pass string) smtp.Auth { return &loginAuthenticator{user, pass} } + +func (a *loginAuthenticator) Start(_ *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", nil, nil +} +func (a *loginAuthenticator) Next(fromServer []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + p := strings.ToLower(string(fromServer)) + switch { + case strings.Contains(p, "user"): + return []byte(a.user), nil + case strings.Contains(p, "pass"): + return []byte(a.pass), nil + default: + return nil, fmt.Errorf("неожиданный запрос сервера при LOGIN: %q", fromServer) + } +} + +func splitRecipients(s string) []string { + var out []string + for _, p := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' }) { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// buildMIME собирает простое UTF-8 письмо (заголовки + текст). +func buildMIME(from string, to []string, subject, body string) []byte { + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + strings.Join(to, ", ") + "\r\n") + // RFC 2047 — тема на кириллице в base64 UTF-8. + b.WriteString("Subject: " + encodeHeaderWord(subject) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n") + b.WriteString("\r\n") + b.WriteString(body) + b.WriteString("\r\n") + return []byte(b.String()) +} + +// encodeHeaderWord кодирует заголовок как =?UTF-8?B?...?= (для кириллицы). +func encodeHeaderWord(s string) string { + return mime.BEncoding.Encode("UTF-8", s) +} + +// --- Zabbix trapper (протокол zabbix_sender) --- + +// sendZabbix шлёт значение элемента-trapper на сервер/прокси Zabbix. +// Возвращает строку info из ответа сервера (напр. "processed 1; failed 0"). +func sendZabbix(z ZabbixSettings, value string) (string, error) { + if strings.TrimSpace(z.Server) == "" { + return "", fmt.Errorf("не задан адрес сервера Zabbix") + } + if strings.TrimSpace(z.Host) == "" || strings.TrimSpace(z.Key) == "" { + return "", fmt.Errorf("не заданы host/key элемента Zabbix") + } + port := z.Port + if port == 0 { + port = 10051 + } + payload, _ := json.Marshal(map[string]any{ + "request": "sender data", + "data": []map[string]any{{ + "host": z.Host, + "key": z.Key, + "value": value, + }}, + }) + // Заголовок протокола: "ZBXD" \x01 + 8 байт длины (LE) + данные. + header := make([]byte, 13) + copy(header, "ZBXD") + header[4] = 0x01 + binary.LittleEndian.PutUint64(header[5:], uint64(len(payload))) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + d := &net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", z.Server, port)) + if err != nil { + return "", fmt.Errorf("соединение: %w", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err := conn.Write(append(header, payload...)); err != nil { + return "", fmt.Errorf("отправка: %w", err) + } + // Ответ: 13-байтный заголовок + JSON. + respHdr := make([]byte, 13) + if _, err := readFull(conn, respHdr); err != nil { + return "", fmt.Errorf("ответ (заголовок): %w", err) + } + if string(respHdr[:4]) != "ZBXD" { + return "", fmt.Errorf("неожиданный ответ сервера Zabbix") + } + n := binary.LittleEndian.Uint64(respHdr[5:]) + if n == 0 || n > 1<<20 { + return "принято", nil + } + buf := make([]byte, n) + if _, err := readFull(conn, buf); err != nil { + return "", fmt.Errorf("ответ (тело): %w", err) + } + var r struct { + Response string `json:"response"` + Info string `json:"info"` + } + if json.Unmarshal(buf, &r) == nil { + if r.Response != "success" { + return "", fmt.Errorf("Zabbix отклонил: %s", r.Info) + } + if strings.Contains(r.Info, "failed 1") || strings.Contains(r.Info, "failed: 1") { + return "", fmt.Errorf("Zabbix не принял значение (%s) — проверьте host/key в Zabbix", r.Info) + } + return r.Info, nil + } + return "принято", nil +} + +func readFull(conn net.Conn, buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := conn.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} + +// alertKind — тип оповещения по заголовку (для маршрутизации по группам адресов). +func alertKind(title string) string { + switch { + case strings.HasPrefix(title, "НРД: депо-поручение") || strings.HasPrefix(title, "НРД: истёк срок"): + return "orders" + case strings.Contains(title, "QUIK"): + return "quik" + } + return "system" +} + +// mergeEmails — объединение списков адресов без дублей. +func mergeEmails(lists ...string) string { + seen := map[string]bool{} + var out []string + for _, l := range lists { + for _, e := range strings.Split(l, ",") { + e = strings.TrimSpace(e) + if e == "" || seen[strings.ToLower(e)] { + continue + } + seen[strings.ToLower(e)] = true + out = append(out, e) + } + } + return strings.Join(out, ", ") +} diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go new file mode 100644 index 0000000..4d4fd9e --- /dev/null +++ b/internal/lkgateway/auth.go @@ -0,0 +1,1308 @@ +package lkgateway + +// Пользователи, роли и вход в bj. +// +// Вход — локальные учётные записи (bcrypt) и/или Active Directory (LDAP bind +// учётными данными самого пользователя, роль по группам AD). Сессия — cookie +// bj_session, хранится на сервере (/var/lib/bj/.bj/sessions.json), чтобы +// переживать перезапуск. Пока не создан ни один пользователь, система открыта +// (режим первичной настройки) — так текущие установки не блокируются. +// +// Роли (roleDefs): admin — всё; operator — работа с переводами и обменом; +// risk — только риск-монитор НПР/УДС; viewer — просмотр без изменений. +// Модули (npr, quik, export) можно дополнительно выдать пользователю поверх роли. + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "fmt" + "html/template" + "log" + "net" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" + "golang.org/x/crypto/bcrypt" +) + +const ( + usersFile = "/var/lib/bj/.bj/users.json" + sessionsFile = "/var/lib/bj/.bj/sessions.json" + sessionCookie = "bj_session" + sessionMaxLife = 24 * time.Hour +) + +// ---------- роли и права ---------- + +type roleDef struct { + Key, Title, Desc string + Perms []string +} + +// Права: view — просмотр журнала/переводов; operate — действия по переводам; +// settings — настройка системы и пользователи; npr/quik/export — модули. +var roleDefs = []roleDef{ + {"admin", "Администратор", "Полный доступ: переводы, настройки, пользователи, все модули.", []string{"view", "operate", "settings", "npr", "quik", "export", "users"}}, + {"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK, тестирование M2M. Без настроек.", []string{"view", "operate", "quik"}}, + {"risk", "Риск-менеджер", "Только риск-монитор НПР / УДС.", []string{"npr"}}, + {"viewer", "Наблюдатель", "Просмотр журнала и переводов без права действий.", []string{"view"}}, +} + +var moduleDefs = []struct{ Key, Title string }{ + {"npr", "Риск-монитор НПР / УДС"}, + {"quik", "Поручения QUIK (МНП)"}, + {"export", "Экспорт биржевой информации (просмотр состояния)"}, +} + +func roleByKey(k string) (roleDef, bool) { + for _, r := range roleDefs { + if r.Key == k { + return r, true + } + } + return roleDef{}, false +} + +// permFeature — права модулей и лицензионные признаки, без которых они не действуют. +// Остальные права (view, operate, settings, users) базовые: модуль прав не лицензируется. +var permFeature = map[string]string{"npr": "npr", "quik": "quik-mnp", "export": quikExportFeature} + +// featureAllowed — признак есть в текущей лицензии (подменяется в тестах). +var featureAllowed = func(feature string) bool { + if globalRC == nil { + return true + } + return moduleAllowed(globalRC, feature) +} + +func permLicensed(perm string) bool { + f, ok := permFeature[perm] + return !ok || featureAllowed(f) +} + +// roleAvailable — у роли осталось хотя бы одно действующее право. +func roleAvailable(k string) bool { + r, ok := roleByKey(k) + if !ok { + return false + } + for _, p := range r.Perms { + if permLicensed(p) { + return true + } + } + return false +} + +// roleMissingModules — названия неподключённых модулей, от которых зависит роль. +func roleMissingModules(k string) string { + r, _ := roleByKey(k) + var out []string + for _, p := range r.Perms { + if permLicensed(p) { + continue + } + for _, d := range moduleDefs { + if d.Key == p { + out = append(out, d.Title) + } + } + } + return strings.Join(out, ", ") +} + +func availableRoles() []roleDef { + var out []roleDef + for _, r := range roleDefs { + if roleAvailable(r.Key) { + out = append(out, r) + } + } + return out +} + +func availableModules() []struct{ Key, Title string } { + var out []struct{ Key, Title string } + for _, d := range moduleDefs { + if permLicensed(d.Key) { + out = append(out, d) + } + } + return out +} + +// userHasAccess — пользователю доступен хоть один раздел при текущей лицензии. +func userHasAccess(u *User) bool { + if roleAvailable(u.Role) { + return true + } + for _, m := range u.Modules { + if permLicensed(m) { + return true + } + } + return false +} + +func roleTitle(k string) string { + if r, ok := roleByKey(k); ok { + return r.Title + } + return k +} + +// ---------- пользователи ---------- + +type User struct { + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + Role string `json:"role"` + Modules []string `json:"modules,omitempty"` // дополнительные модули поверх роли + Source string `json:"source"` // local | ad + PassHash string `json:"pass_hash,omitempty"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + LastLogin time.Time `json:"last_login,omitempty"` + ADGroups []string `json:"ad_groups,omitempty"` + Note string `json:"note,omitempty"` +} + +func (u *User) Can(perm string) bool { + if u == nil || !permLicensed(perm) { + return false + } + if r, ok := roleByKey(u.Role); ok { + for _, p := range r.Perms { + if p == perm { + return true + } + } + } + for _, m := range u.Modules { + if m == perm { + return true + } + } + return false +} + +func (u *User) RoleTitle() string { return roleTitle(u.Role) } + +// Home — стартовая страница по роли. +func (u *User) Home() string { + if u.Can("view") { + return "/admin/" + } + if u.Can("npr") { + return "/admin/npr" + } + if u.Can("quik") { + return "/admin/quik" + } + return "/admin/" +} + +type userStore struct { + mu sync.Mutex + users map[string]*User // ключ — login в нижнем регистре +} + +var globalUsers = loadUsers() + +func loadUsers() *userStore { + s := &userStore{users: map[string]*User{}} + if b, err := os.ReadFile(usersFile); err == nil { + var list []*User + if json.Unmarshal(b, &list) == nil { + for _, u := range list { + s.users[strings.ToLower(u.Login)] = u + } + } + } + return s +} + +func (s *userStore) save() error { + list := s.list() + b, _ := json.MarshalIndent(list, "", " ") + tmp := usersFile + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, usersFile) +} + +func (s *userStore) list() []*User { + out := make([]*User, 0, len(s.users)) + for _, u := range s.users { + cp := *u + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Login) < strings.ToLower(out[j].Login) }) + return out +} + +func (s *userStore) List() []*User { + s.mu.Lock() + defer s.mu.Unlock() + return s.list() +} + +func (s *userStore) Count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.users) +} + +func (s *userStore) Get(login string) *User { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.users[strings.ToLower(strings.TrimSpace(login))]; ok { + cp := *u + return &cp + } + return nil +} + +func (s *userStore) Put(u *User) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *u + s.users[strings.ToLower(u.Login)] = &cp + return s.save() +} + +func (s *userStore) Delete(login string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.users, strings.ToLower(login)) + return s.save() +} + +func (s *userStore) update(login string, fn func(u *User)) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[strings.ToLower(login)] + if !ok { + return fmt.Errorf("пользователь %s не найден", login) + } + fn(u) + return s.save() +} + +// AdminCount — сколько активных администраторов (чтобы не удалить последнего). +func (s *userStore) AdminCount() int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, u := range s.users { + if u.Role == "admin" && u.Active { + n++ + } + } + return n +} + +func hashPassword(p string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(p), 12) + return string(b), err +} + +func checkPassword(hash, p string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil +} + +// authOpen — режим первичной настройки: пользователей нет, вход не требуется. +func authOpen() bool { return globalUsers.Count() == 0 } + +// ---------- сессии ---------- + +type Session struct { + Token string `json:"token"` + Login string `json:"login"` + Created time.Time `json:"created"` + Seen time.Time `json:"seen"` + IP string `json:"ip"` + UA string `json:"ua"` +} + +type sessionStore struct { + mu sync.Mutex + list map[string]*Session +} + +var globalSessions = loadSessions() + +func loadSessions() *sessionStore { + s := &sessionStore{list: map[string]*Session{}} + if b, err := os.ReadFile(sessionsFile); err == nil { + var l []*Session + if json.Unmarshal(b, &l) == nil { + for _, x := range l { + if time.Since(x.Created) < sessionMaxLife { + s.list[x.Token] = x + } + } + } + } + return s +} + +func (s *sessionStore) save() { + l := make([]*Session, 0, len(s.list)) + for _, x := range s.list { + l = append(l, x) + } + b, _ := json.Marshal(l) + tmp := sessionsFile + ".tmp" + if os.WriteFile(tmp, b, 0o600) == nil { + _ = os.Rename(tmp, sessionsFile) + } +} + +func (s *sessionStore) New(login, ip, ua string) *Session { + raw := make([]byte, 32) + _, _ = rand.Read(raw) + x := &Session{Token: hex.EncodeToString(raw), Login: login, Created: time.Now(), Seen: time.Now(), IP: ip, UA: ua} + s.mu.Lock() + s.list[x.Token] = x + s.save() + s.mu.Unlock() + return x +} + +func (s *sessionStore) Get(token string, idle time.Duration) *Session { + s.mu.Lock() + defer s.mu.Unlock() + x, ok := s.list[token] + if !ok { + return nil + } + if time.Since(x.Created) > sessionMaxLife || time.Since(x.Seen) > idle { + delete(s.list, token) + s.save() + return nil + } + if time.Since(x.Seen) > time.Minute { + x.Seen = time.Now() + s.save() + } + return x +} + +func (s *sessionStore) Drop(token string) { + s.mu.Lock() + delete(s.list, token) + s.save() + s.mu.Unlock() +} + +// DropUser — завершить все сессии пользователя (блокировка, смена роли). +func (s *sessionStore) DropUser(login string) { + s.mu.Lock() + for t, x := range s.list { + if strings.EqualFold(x.Login, login) { + delete(s.list, t) + } + } + s.save() + s.mu.Unlock() +} + +func (s *sessionStore) Active() []*Session { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*Session, 0, len(s.list)) + for _, x := range s.list { + if time.Since(x.Created) < sessionMaxLife { + cp := *x + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Seen.After(out[j].Seen) }) + return out +} + +// ---------- защита от перебора ---------- + +var loginFails = struct { + mu sync.Mutex + m map[string][]time.Time +}{m: map[string][]time.Time{}} + +func loginFailCount(key string) int { + loginFails.mu.Lock() + defer loginFails.mu.Unlock() + var keep []time.Time + for _, t := range loginFails.m[key] { + if time.Since(t) < 15*time.Minute { + keep = append(keep, t) + } + } + loginFails.m[key] = keep + return len(keep) +} + +func loginFailAdd(key string) { + loginFails.mu.Lock() + loginFails.m[key] = append(loginFails.m[key], time.Now()) + loginFails.mu.Unlock() +} + +func loginFailReset(key string) { + loginFails.mu.Lock() + delete(loginFails.m, key) + loginFails.mu.Unlock() +} + +// ---------- настройки входа (AD) ---------- + +type ADGroupRole struct { + Group string `json:"group"` // имя группы (CN) или полный DN + Role string `json:"role"` +} + +type AuthSettings struct { + SessionHours int `json:"session_hours"` // бездействие до выхода (по умолчанию 8) + + ADEnabled bool `json:"ad_enabled"` + ADServer string `json:"ad_server"` // ldaps://dc1.corp.example.ru:636 или ldap://…:389 + ADDomain string `json:"ad_domain"` // corp.example.ru — суффикс UPN для входа + ADBaseDN string `json:"ad_base_dn"` // DC=corp,DC=example,DC=ru + ADStartTLS bool `json:"ad_starttls"` // для ldap:// — поднимать TLS + ADSkipVerify bool `json:"ad_skip_verify"` + ADCAPEM string `json:"ad_ca_pem"` // сертификат корпоративного ЦС (PEM), если проверяем + ADGroups []ADGroupRole `json:"ad_groups"` // группа → роль (первое совпадение) + ADDefaultRole string `json:"ad_default_role"` // роль, если группы не совпали ("" = отказать) + ADModulesByRole bool `json:"-"` + + // Перепроверка групп доменных пользователей (adrecheck.go): поиск от имени + // сервисной учётной записи только на чтение. Пусто — роль меняется только при входе. + ADBindUser string `json:"ad_bind_user"` // svc-bj@corp.example.ru, CORP\svc-bj или DN + ADBindPassword string `json:"ad_bind_password"` + ADRecheckMinutes int `json:"ad_recheck_minutes"` // 0 = 15 +} + +func (a AuthSettings) idle() time.Duration { + if a.SessionHours <= 0 { + return 8 * time.Hour + } + return time.Duration(a.SessionHours) * time.Hour +} + +func (r *RuntimeConfig) UpdateAuth(s AuthSettings) error { + r.mu.Lock() + r.data.Auth = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// ---------- Active Directory ---------- + +type adResult struct { + DN string + Name string + Email string + Groups []string // DN групп (memberOf) + Role string + MatchedBy string +} + +func adDial(a AuthSettings) (*ldap.Conn, error) { + server := strings.TrimSpace(a.ADServer) + if server == "" { + return nil, fmt.Errorf("не задан адрес контроллера домена") + } + if !strings.Contains(server, "://") { + server = "ldaps://" + server + } + u, err := url.Parse(server) + if err != nil { + return nil, err + } + host := u.Hostname() + tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: a.ADSkipVerify, MinVersion: tls.VersionTLS12} + if pem := strings.TrimSpace(a.ADCAPEM); pem != "" && !a.ADSkipVerify { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(pem)) { + return nil, fmt.Errorf("сертификат ЦС не разобран (ожидается PEM)") + } + tlsCfg.RootCAs = pool + } + dialer := &net.Dialer{Timeout: 8 * time.Second} + conn, err := ldap.DialURL(server, ldap.DialWithDialer(dialer), ldap.DialWithTLSConfig(tlsCfg)) + if err != nil { + return nil, fmt.Errorf("подключение к %s: %v", server, err) + } + conn.SetTimeout(10 * time.Second) + if u.Scheme == "ldap" && a.ADStartTLS { + if err := conn.StartTLS(tlsCfg); err != nil { + conn.Close() + return nil, fmt.Errorf("StartTLS: %v", err) + } + } + return conn, nil +} + +// adAuthenticate — bind учётными данными пользователя, затем поиск его карточки +// и проверка членства в сопоставленных группах (с вложенностью, +// LDAP_MATCHING_RULE_IN_CHAIN). +func adAuthenticate(a AuthSettings, login, password string) (*adResult, error) { + login = strings.TrimSpace(login) + if password == "" { + return nil, fmt.Errorf("пустой пароль") + } + conn, err := adDial(a) + if err != nil { + return nil, err + } + defer conn.Close() + sam, upn := adSplitLogin(a, login) + if err := conn.Bind(upn, password); err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { + return nil, fmt.Errorf("неверный логин или пароль (AD)") + } + return nil, fmt.Errorf("AD bind: %v", err) + } + res, base, err := adLookup(conn, a, sam, upn) + if err == errADNotFound { + return nil, fmt.Errorf("пользователь %s не найден в %s", sam, base) + } + if err != nil { + return res, err + } + if err := adResolveRole(conn, a, base, sam, res); err != nil { + return res, err + } + return res, nil +} + +// adLookup — карточка пользователя в AD. errADNotFound / errADDisabled — +// ответ каталога; прочие ошибки — сбой поиска. +func adLookup(conn *ldap.Conn, a AuthSettings, sam, upn string) (*adResult, string, error) { + base := a.ADBaseDN + if base == "" { + base = adDefaultBase(conn) + } + req := ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, + fmt.Sprintf("(&(objectClass=user)(|(sAMAccountName=%s)(userPrincipalName=%s)))", ldap.EscapeFilter(sam), ldap.EscapeFilter(upn)), + []string{"distinguishedName", "displayName", "mail", "memberOf", "userAccountControl"}, nil) + sr, err := conn.Search(req) + if err != nil { + return nil, base, fmt.Errorf("поиск пользователя в AD: %v", err) + } + if len(sr.Entries) == 0 { + return nil, base, errADNotFound + } + e := sr.Entries[0] + res := &adResult{DN: e.DN, Name: e.GetAttributeValue("displayName"), Email: e.GetAttributeValue("mail"), Groups: e.GetAttributeValues("memberOf")} + if uac := e.GetAttributeValue("userAccountControl"); uac != "" { + var n int + fmt.Sscanf(uac, "%d", &n) + if n&2 != 0 { + return res, base, errADDisabled + } + } + return res, base, nil +} + +// adNoGroupError — пользователь не состоит ни в одной сопоставленной группе +// (в отличие от сбоя поиска, после которого роль менять нельзя). +type adNoGroupError struct{ sam string } + +func (e *adNoGroupError) Error() string { + return fmt.Sprintf("пользователь %s вошёл в AD, но не состоит ни в одной из сопоставленных групп", e.sam) +} + +// adResolveRole — роль по группам: первое совпадение в порядке настройки. +func adResolveRole(conn *ldap.Conn, a AuthSettings, base, sam string, res *adResult) error { + for _, gr := range a.ADGroups { + g := strings.TrimSpace(gr.Group) + if g == "" || !roleAvailable(gr.Role) { + continue + } + dn := g + if !strings.Contains(strings.ToLower(g), "dc=") { + // имя группы → DN + gs, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, + fmt.Sprintf("(&(objectClass=group)(|(cn=%s)(sAMAccountName=%s)))", ldap.EscapeFilter(g), ldap.EscapeFilter(g)), []string{"distinguishedName"}, nil)) + if err != nil { + return fmt.Errorf("поиск группы %s в AD: %v", g, err) + } + if len(gs.Entries) == 0 { + continue + } + dn = gs.Entries[0].DN + } + // прямое членство + matched := false + for _, m := range res.Groups { + if strings.EqualFold(m, dn) { + matched = true + break + } + } + if !matched { + // вложенное членство + cs, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 10, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s)(memberOf:1.2.840.113556.1.4.1941:=%s))", ldap.EscapeFilter(sam), ldap.EscapeFilter(dn)), []string{"distinguishedName"}, nil)) + if err != nil { + return fmt.Errorf("проверка вложенного членства в %s: %v", g, err) + } + matched = len(cs.Entries) > 0 + } + if matched { + res.Role, res.MatchedBy = gr.Role, g + return nil + } + } + if a.ADDefaultRole != "" && roleAvailable(a.ADDefaultRole) { + res.Role, res.MatchedBy = a.ADDefaultRole, "роль по умолчанию" + return nil + } + return &adNoGroupError{sam: sam} +} + +func adDefaultBase(conn *ldap.Conn) string { + sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 5, false, "(objectClass=*)", []string{"defaultNamingContext"}, nil)) + if err == nil && len(sr.Entries) > 0 { + return sr.Entries[0].GetAttributeValue("defaultNamingContext") + } + return "" +} + +// adProbe — проверка доступности контроллера (rootDSE без bind). +func adProbe(a AuthSettings) (string, error) { + conn, err := adDial(a) + if err != nil { + return "", err + } + defer conn.Close() + sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 5, false, "(objectClass=*)", []string{"defaultNamingContext", "dnsHostName"}, nil)) + if err != nil { + return "", fmt.Errorf("rootDSE: %v", err) + } + if len(sr.Entries) == 0 { + return "", fmt.Errorf("контроллер не вернул rootDSE") + } + e := sr.Entries[0] + return fmt.Sprintf("контроллер %s, база %s", e.GetAttributeValue("dnsHostName"), e.GetAttributeValue("defaultNamingContext")), nil +} + +// ---------- вход ---------- + +// authenticate — локальный пользователь, затем AD. Возвращает пользователя +// (для AD — создаёт/обновляет локальную запись). +func authenticate(rc *RuntimeConfig, login, password string) (*User, error) { + login = strings.TrimSpace(login) + if login == "" || password == "" { + return nil, fmt.Errorf("введите логин и пароль") + } + a := rc.Snapshot().Auth + u := globalUsers.Get(login) + if u != nil && u.Source != "ad" { + if !u.Active { + return nil, fmt.Errorf("учётная запись заблокирована") + } + if u.PassHash != "" && checkPassword(u.PassHash, password) { + if !userHasAccess(u) { + return nil, fmt.Errorf("роль «%s» недоступна: в лицензии нет модуля %s", u.RoleTitle(), roleMissingModules(u.Role)) + } + return u, nil + } + if !a.ADEnabled { + return nil, fmt.Errorf("неверный логин или пароль") + } + } + if !a.ADEnabled { + return nil, fmt.Errorf("неверный логин или пароль") + } + res, err := adAuthenticate(a, login, password) + if err != nil { + return nil, err + } + key := login + if i := strings.Index(key, "@"); i > 0 { + key = key[:i] + } + if i := strings.Index(key, `\`); i > 0 { + key = key[i+1:] + } + if u = globalUsers.Get(key); u == nil { + u = &User{Login: key, Source: "ad", Active: true, CreatedAt: time.Now(), Role: res.Role} + } else if !u.Active { + return nil, fmt.Errorf("учётная запись заблокирована в bj") + } + u.Source = "ad" + u.Role = res.Role + if res.Name != "" { + u.Name = res.Name + } + if res.Email != "" { + u.Email = res.Email + } + u.ADGroups = nil + for _, g := range res.Groups { + u.ADGroups = append(u.ADGroups, dnCN(g)) + } + u.Note = "роль по AD: " + res.MatchedBy + if err := globalUsers.Put(u); err != nil { + return nil, err + } + return u, nil +} + +func dnCN(dn string) string { + for _, part := range strings.Split(dn, ",") { + if strings.HasPrefix(strings.ToLower(part), "cn=") { + return part[3:] + } + } + return dn +} + +// ---------- middleware ---------- + +type authRW struct { + http.ResponseWriter + user *User +} + +func (a *authRW) Unwrap() http.ResponseWriter { return a.ResponseWriter } +func (a *authRW) Flush() { + if f, ok := a.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func currentUser(w http.ResponseWriter) *User { + for w != nil { + if a, ok := w.(*authRW); ok { + return a.user + } + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return nil + } + w = u.Unwrap() + } + return nil +} + +// authExempt — пути без входа: API модулей (свой ключ/токен), health, статика. +func authExempt(p string) bool { + switch { + case p == "/login" || p == "/logout" || p == "/healthz": + return true + case strings.HasPrefix(p, "/api/"): + return true + case p == "/admin/api/callback-url": // служебный вызов эмулятора ЛК + return true + case strings.HasPrefix(p, "/admin/npr/static/"): + return true + } + return false +} + +// requiredPerm — какое право нужно для пути и метода. +func requiredPerm(p, method string) string { + switch { + case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard": + return "settings" + case strings.HasPrefix(p, "/admin/npr"): + return "npr" + case strings.HasPrefix(p, "/admin/quik"): + return "quik" + case p == "/admin/api/clients": + return "view" + } + if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { + return "operate" + } + return "view" +} + +// clientIP — адрес клиента. X-Real-IP учитывается только от локального прокси: иначе его +// подставит кто угодно и обойдёт лимит попыток входа и fail2ban. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + if h := strings.TrimSpace(r.Header.Get("X-Real-IP")); h != "" { + return h + } + } + return host +} + +func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if authExempt(p) { + next.ServeHTTP(w, r) + return + } + if authOpen() { + next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r) + return + } + var u *User + if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { + if s := globalSessions.Get(c.Value, rc.Snapshot().Auth.idle()); s != nil { + u = globalUsers.Get(s.Login) + if u != nil && !u.Active { + globalSessions.Drop(c.Value) + u = nil + } + } + } + if u == nil { + if isAjax(r) { + http.Error(w, "требуется вход", http.StatusUnauthorized) + return + } + next := r.URL.RequestURI() + http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther) + return + } + perm := requiredPerm(p, r.Method) + if !u.Can(perm) { + // пользователь без права просмотра — отправляем на его стартовую страницу + if r.Method == http.MethodGet && (p == "/admin" || p == "/admin/") && !u.Can("view") && u.Home() != "/admin/" { + http.Redirect(w, r, u.Home(), http.StatusSeeOther) + return + } + forbidden(w, r, u, perm) + return + } + next.ServeHTTP(&authRW{ResponseWriter: w, user: u}, r) + }) +} + +func isAjax(r *http.Request) bool { + return r.Header.Get("HX-Request") != "" || strings.Contains(r.Header.Get("Accept"), "application/json") || strings.HasPrefix(r.URL.Path, "/admin/npr/api/") || strings.HasPrefix(r.URL.Path, "/admin/incoming/feed") +} + +var permTitles = map[string]string{"view": "просмотр переводов", "operate": "действия по переводам", "settings": "настройка системы", "npr": "модуль НПР / УДС", "quik": "поручения QUIK", "export": "экспорт QUIK", "users": "управление пользователями"} + +func forbidden(w http.ResponseWriter, r *http.Request, u *User, perm string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = loginTpl.ExecuteTemplate(w, "login", loginData{Title: "Нет доступа", Forbidden: true, User: u, Perm: permTitles[perm], Next: u.Home()}) +} + +// ---------- страница входа ---------- + +type loginData struct { + Title string + Error string + Login string + Next string + ADEnabled bool + ADDomain string + Forbidden bool + User *User + Perm string + Brand string +} + +var loginTpl = template.Must(template.New("login").Parse(loginHTML)) + +func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + a := rc.Snapshot().Auth + next := safeReturnPath(r.FormValue("next")) + if authOpen() { + http.Redirect(w, r, "/admin/setup#users", http.StatusSeeOther) + return + } + d := loginData{Title: "Вход", Next: next, ADEnabled: a.ADEnabled, ADDomain: a.ADDomain} + if r.Method == http.MethodPost { + login := strings.TrimSpace(r.FormValue("login")) + d.Login = login + key := clientIP(r) + "|" + strings.ToLower(login) + if loginFailCount(key) >= 8 { + d.Error = "Слишком много неудачных попыток. Подождите 15 минут." + w.WriteHeader(http.StatusTooManyRequests) + _ = loginTpl.ExecuteTemplate(w, "login", d) + return + } + u, err := authenticate(rc, login, r.FormValue("password")) + if err != nil { + loginFailAdd(key) + time.Sleep(700 * time.Millisecond) + log.Printf("auth: отказ входа %q с %s: %v", login, clientIP(r), err) + d.Error = err.Error() + w.WriteHeader(http.StatusUnauthorized) + _ = loginTpl.ExecuteTemplate(w, "login", d) + return + } + loginFailReset(key) + _ = globalUsers.update(u.Login, func(x *User) { x.LastLogin = time.Now() }) + s := globalSessions.New(u.Login, clientIP(r), r.UserAgent()) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) + log.Printf("auth: вход %s (%s, %s) с %s", u.Login, u.Role, u.Source, clientIP(r)) + if next == "" || next == "/login" { + next = u.Home() + } + http.Redirect(w, r, next, http.StatusSeeOther) + return + } + w.Header().Set("Cache-Control", "no-store") + _ = loginTpl.ExecuteTemplate(w, "login", d) + }) + mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + globalSessions.Drop(c.Value) + } + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, MaxAge: -1}) + http.Redirect(w, r, "/login", http.StatusSeeOther) + }) +} + +// ---------- обработчики настроек ---------- + +type UsersView struct { + Users []*User + Roles []roleDef // доступные при текущей лицензии + Hidden []struct{ Title, Missing string } // роли, недоступные без модулей + Modules []struct{ Key, Title string } // подключённые модули + Sessions []*Session + Open bool + Me *User + ADRecheck ADRecheckStatus +} + +func usersView(w http.ResponseWriter) UsersView { + v := UsersView{Users: globalUsers.List(), Roles: availableRoles(), Modules: availableModules(), Sessions: globalSessions.Active(), Open: authOpen(), Me: currentUser(w), ADRecheck: adRecheckSnapshot()} + for _, r := range roleDefs { + if !roleAvailable(r.Key) { + v.Hidden = append(v.Hidden, struct{ Title, Missing string }{r.Title, roleMissingModules(r.Key)}) + } + } + return v +} + +// RoleShown — роль есть в списке доступных (иначе в выпадающих списках она +// показывается отдельной пометкой, чтобы сохранение не подменило её первой по списку). +func (v UsersView) RoleShown(k string) bool { + for _, r := range v.Roles { + if r.Key == k { + return true + } + } + return false +} + +func (v UsersView) RoleNote(k string) string { + if _, ok := roleByKey(k); !ok { + return "без роли — доступ снят" + } + return roleTitle(k) + " — нет модуля: " + roleMissingModules(k) +} + +func (h *setupHandlers) usersFlash(w http.ResponseWriter, r *http.Request, msg string) { + http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg)+"#users", http.StatusSeeOther) +} + +func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + me := currentUser(w) + login := strings.TrimSpace(r.FormValue("login")) + if login == "" || strings.ContainsAny(login, " /\\@\"'<>") { + h.usersFlash(w, r, "Пользователи: логин пустой или содержит недопустимые символы.") + return + } + role := r.FormValue("role") + if _, ok := roleByKey(role); !ok { + h.usersFlash(w, r, "Пользователи: неизвестная роль.") + return + } + u := globalUsers.Get(login) + isNew := u == nil + if isNew { + u = &User{Login: login, Source: "local", Active: true, CreatedAt: time.Now()} + } + if u.Role != role && !roleAvailable(role) { + h.usersFlash(w, r, "Пользователи: роль «"+roleTitle(role)+"» недоступна — в лицензии нет модуля: "+roleMissingModules(role)+".") + return + } + wasAdmin := u.Role == "admin" && u.Active + u.Name = strings.TrimSpace(r.FormValue("name")) + u.Email = strings.TrimSpace(r.FormValue("email")) + u.Role = role + // модули вне лицензии в форме не показываются — ранее выданные сохраняем до её расширения + var mods []string + for _, m := range u.Modules { + if !permLicensed(m) { + mods = append(mods, m) + } + } + for _, m := range r.Form["modules"] { + for _, d := range availableModules() { + if d.Key == m { + mods = append(mods, m) + } + } + } + u.Modules = mods + u.Active = r.FormValue("active") != "" || isNew + if src := r.FormValue("source"); src == "ad" || src == "local" { + u.Source = src + } + pw := r.FormValue("password") + if u.Source == "local" { + if pw != "" { + if len(pw) < 8 { + h.usersFlash(w, r, "Пользователи: пароль короче 8 символов.") + return + } + hsh, err := hashPassword(pw) + if err != nil { + h.usersFlash(w, r, "Пользователи: "+err.Error()) + return + } + u.PassHash = hsh + } else if isNew { + h.usersFlash(w, r, "Пользователи: для локального пользователя задайте пароль.") + return + } + } + // защита от потери последнего администратора + if wasAdmin && (u.Role != "admin" || !u.Active) && globalUsers.AdminCount() <= 1 { + h.usersFlash(w, r, "Пользователи: нельзя понизить или заблокировать последнего администратора.") + return + } + if me != nil && strings.EqualFold(me.Login, u.Login) && (u.Role != "admin" || !u.Active) { + h.usersFlash(w, r, "Пользователи: нельзя снять права администратора с самого себя.") + return + } + if err := globalUsers.Put(u); err != nil { + h.usersFlash(w, r, "Пользователи: "+err.Error()) + return + } + if !u.Active || !isNew { + if me == nil || !strings.EqualFold(me.Login, u.Login) { + globalSessions.DropUser(u.Login) + } + } + who := "—" + if me != nil { + who = me.Login + } + log.Printf("auth: пользователь %s сохранён (%s, роль %s, активен %v) кем: %s", u.Login, u.Source, u.Role, u.Active, who) + if isNew && globalUsers.Count() == 1 { + h.usersFlash(w, r, "Создан первый администратор "+u.Login+". Теперь вход обязателен — войдите под ним.") + return + } + h.usersFlash(w, r, "Пользователь "+u.Login+" сохранён.") +} + +func (h *setupHandlers) deleteUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + login := strings.TrimSpace(r.FormValue("login")) + u := globalUsers.Get(login) + if u == nil { + h.usersFlash(w, r, "Пользователи: не найден.") + return + } + me := currentUser(w) + if me != nil && strings.EqualFold(me.Login, login) { + h.usersFlash(w, r, "Пользователи: нельзя удалить самого себя.") + return + } + if u.Role == "admin" && u.Active && globalUsers.AdminCount() <= 1 { + h.usersFlash(w, r, "Пользователи: нельзя удалить последнего администратора.") + return + } + _ = globalUsers.Delete(login) + globalSessions.DropUser(login) + h.usersFlash(w, r, "Пользователь "+login+" удалён.") +} + +func (h *setupHandlers) dropSession(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + globalSessions.Drop(r.FormValue("token")) + h.usersFlash(w, r, "Сессия завершена.") +} + +func (h *setupHandlers) saveAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Auth + fmt.Sscanf(r.FormValue("session_hours"), "%d", &a.SessionHours) + a.ADEnabled = r.FormValue("ad_enabled") != "" + a.ADServer = strings.TrimSpace(r.FormValue("ad_server")) + a.ADDomain = strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")) + a.ADBaseDN = strings.TrimSpace(r.FormValue("ad_base_dn")) + a.ADStartTLS = r.FormValue("ad_starttls") != "" + a.ADSkipVerify = r.FormValue("ad_skip_verify") != "" + a.ADCAPEM = strings.TrimSpace(r.FormValue("ad_ca_pem")) + a.ADBindUser = strings.TrimSpace(r.FormValue("ad_bind_user")) + if pw := r.FormValue("ad_bind_password"); pw != "" { + a.ADBindPassword = pw // пустое поле не затирает сохранённый пароль + } + if a.ADBindUser == "" { + a.ADBindPassword = "" + } + a.ADRecheckMinutes = 0 + fmt.Sscanf(r.FormValue("ad_recheck_minutes"), "%d", &a.ADRecheckMinutes) + a.ADDefaultRole = r.FormValue("ad_default_role") + if _, ok := roleByKey(a.ADDefaultRole); !ok { + a.ADDefaultRole = "" + } + a.ADGroups = nil + groups, roles := r.Form["ad_group"], r.Form["ad_role"] + for i := range groups { + g := strings.TrimSpace(groups[i]) + if g == "" || i >= len(roles) { + continue + } + if _, ok := roleByKey(roles[i]); ok { + a.ADGroups = append(a.ADGroups, ADGroupRole{Group: g, Role: roles[i]}) + } + } + if err := h.rc.UpdateAuth(a); err != nil { + h.usersFlash(w, r, "Вход: "+err.Error()) + return + } + h.usersFlash(w, r, "Настройки входа сохранены.") +} + +func (h *setupHandlers) testAD(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Auth + login, pw := strings.TrimSpace(r.FormValue("test_login")), r.FormValue("test_password") + if login == "" || pw == "" { + msg, err := adProbe(a) + if err != nil { + h.usersFlash(w, r, "AD: недоступен — "+err.Error()) + return + } + h.usersFlash(w, r, "AD: доступен ("+msg+"). Для проверки ролей введите логин и пароль пользователя домена.") + return + } + res, err := adAuthenticate(a, login, pw) + if err != nil { + extra := "" + if res != nil && len(res.Groups) > 0 { + var names []string + for _, g := range res.Groups { + names = append(names, dnCN(g)) + } + extra = " Группы пользователя: " + strings.Join(names, ", ") + "." + } + h.usersFlash(w, r, "AD: "+err.Error()+"."+extra) + return + } + var names []string + for _, g := range res.Groups { + names = append(names, dnCN(g)) + } + h.usersFlash(w, r, fmt.Sprintf("AD: вход OK — %s (%s), роль «%s» по «%s». Группы: %s.", res.Name, res.DN, roleTitle(res.Role), res.MatchedBy, strings.Join(names, ", "))) +} + +func registerUsersSetup(mux *http.ServeMux, h *setupHandlers) { + mux.HandleFunc("/admin/setup/users", h.saveUser) + mux.HandleFunc("/admin/setup/users/delete", h.deleteUser) + mux.HandleFunc("/admin/setup/users/session-drop", h.dropSession) + mux.HandleFunc("/admin/setup/auth", h.saveAuth) + mux.HandleFunc("/admin/setup/auth/test", h.testAD) + mux.HandleFunc("/admin/setup/auth/recheck", h.recheckAD) +} + +// ---------- функции шаблонов ---------- + +// authTemplateFuncs — заглушки, переопределяются в render на текущего пользователя. +var authTemplateFuncs = template.FuncMap{ + "curUser": func() *User { return nil }, + "can": func(perm string) bool { return true }, + "authOpen": authOpen, +} + +// renderFor — выполняет layout с функциями, привязанными к текущему пользователю. +func renderFor(w http.ResponseWriter, t *template.Template, data any) error { + u := currentUser(w) + t2, err := t.Clone() + if err != nil { + return t.ExecuteTemplate(w, "layout", data) + } + t2.Funcs(template.FuncMap{ + "curUser": func() *User { return u }, + "can": func(perm string) bool { + if authOpen() { + return true + } + return u.Can(perm) + }, + }) + return t2.ExecuteTemplate(w, "layout", data) +} + +// ensureSubtle — используется для сравнения токенов в других местах. +var _ = subtle.ConstantTimeCompare +var _ = context.Background + +const loginHTML = `{{define "login"}}{{.Title}} — Bridge&Join +
    +
    Bridge&Join
    +{{if .Forbidden}} +
    Нет доступа
    +

    Пользователю {{.User.Login}} ({{.User.RoleTitle}}) недоступно: {{.Perm}}.

    +

    Права выдаёт администратор в разделе Настройка → Пользователи.

    +

    На свою страницу · Выйти

    +{{else}} +
    Вход в систему управления
    +
    + + + + +{{if .Error}}
    {{.Error}}
    {{end}} +{{if .ADEnabled}}
    Сотрудники домена входят доменной учётной записью{{if .ADDomain}} ({{.ADDomain}}){{end}}.
    {{end}} +
    +{{end}} +
    {{end}}` diff --git a/internal/lkgateway/auth_license_test.go b/internal/lkgateway/auth_license_test.go new file mode 100644 index 0000000..5492120 --- /dev/null +++ b/internal/lkgateway/auth_license_test.go @@ -0,0 +1,66 @@ +package lkgateway + +import "testing" + +func withFeatures(t *testing.T, features ...string) { + t.Helper() + prev := featureAllowed + featureAllowed = func(f string) bool { + for _, x := range features { + if x == f { + return true + } + } + return false + } + t.Cleanup(func() { featureAllowed = prev }) +} + +func TestRolesFollowLicensedModules(t *testing.T) { + withFeatures(t) // лицензия без модулей + + if roleAvailable("risk") { + t.Fatal("риск-менеджер без модуля НПР должен быть недоступен") + } + for _, k := range []string{"admin", "operator", "viewer"} { + if !roleAvailable(k) { + t.Fatalf("базовая роль %s должна быть доступна", k) + } + } + if got := len(availableModules()); got != 0 { + t.Fatalf("модулей без лицензии: %d, ожидалось 0", got) + } + admin := &User{Role: "admin"} + if admin.Can("npr") || admin.Can("quik") || !admin.Can("settings") || !admin.Can("users") { + t.Fatal("администратор: права модулей должны зависеть от лицензии, базовые — нет") + } + risk := &User{Role: "risk"} + if userHasAccess(risk) || risk.Home() != "/admin/" { + t.Fatal("риск-менеджер без НПР не должен иметь доступа") + } + viewer := &User{Role: "viewer", Modules: []string{"npr"}} + if viewer.Can("npr") { + t.Fatal("выданный модуль вне лицензии не должен действовать") + } +} + +func TestRolesWithLicensedModules(t *testing.T) { + withFeatures(t, "npr", "quik-mnp") + + if !roleAvailable("risk") { + t.Fatal("риск-менеджер с модулем НПР должен быть доступен") + } + op := &User{Role: "operator"} + if !op.Can("quik") { + t.Fatal("оператор с quik-mnp должен видеть поручения QUIK") + } + if (&User{Role: "admin"}).Can("export") { + t.Fatal("экспорт QUIK без признака quik-export не должен действовать") + } + if got := len(availableModules()); got != 2 { + t.Fatalf("подключённых модулей %d, ожидалось 2", got) + } + if (&User{Role: "risk"}).Home() != "/admin/npr" { + t.Fatal("стартовая страница риск-менеджера — НПР") + } +} diff --git a/internal/lkgateway/branding.go b/internal/lkgateway/branding.go new file mode 100644 index 0000000..d8de82a --- /dev/null +++ b/internal/lkgateway/branding.go @@ -0,0 +1,91 @@ +package lkgateway + +// branding.go — настройка логотипа организации в шапке отчётов (загрузка своего +// изображения через UI). Хранится в настройках (Branding.LogoDataURI) как data-URI; +// пусто → встроенный логотип по умолчанию. См. orgLogoIMG в fontvielle_logo.go. + +import ( + "encoding/base64" + "io" + "net/http" + "strings" +) + +const maxLogoBytes = 512 << 10 // 512 КБ — логотип встраивается в каждый отчёт + +// saveLogo принимает загруженное изображение и сохраняет его как логотип отчётов. +func (h *setupHandlers) saveLogo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(maxLogoBytes + (64 << 10)); err != nil { + setupFlash(w, r, "Логотип: не удалось прочитать форму: "+err.Error()) + return + } + file, header, err := r.FormFile("logo") + if err != nil { + setupFlash(w, r, "Логотип: файл не выбран") + return + } + defer file.Close() + + data, err := io.ReadAll(io.LimitReader(file, maxLogoBytes+1)) + if err != nil { + setupFlash(w, r, "Логотип: ошибка чтения файла: "+err.Error()) + return + } + if len(data) == 0 { + setupFlash(w, r, "Логотип: файл пустой") + return + } + if len(data) > maxLogoBytes { + setupFlash(w, r, "Логотип: файл слишком большой (максимум 512 КБ)") + return + } + + mime := logoMIME(header.Filename, data) + if mime == "" { + setupFlash(w, r, "Логотип: нужен файл-изображение (PNG, JPG, GIF, WEBP или SVG)") + return + } + dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data) + if err := h.rc.UpdateBranding(BrandingSettings{LogoDataURI: dataURI, LogoName: header.Filename}); err != nil { + setupFlash(w, r, "Логотип: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Логотип обновлён: "+header.Filename+" — появится в шапке отчётов справа.") +} + +// resetLogo сбрасывает логотип на встроенный по умолчанию. +func (h *setupHandlers) resetLogo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := h.rc.UpdateBranding(BrandingSettings{}); err != nil { + setupFlash(w, r, "Логотип: ошибка сброса: "+err.Error()) + return + } + setupFlash(w, r, "Логотип сброшен на стандартный (встроенный Фонтвьель).") +} + +// logoMIME определяет MIME изображения по расширению и содержимому; "" — не картинка. +func logoMIME(filename string, data []byte) string { + switch { + case strings.HasSuffix(strings.ToLower(filename), ".svg"): + return "image/svg+xml" + } + ct := http.DetectContentType(data) + head := data + if len(head) > 200 { + head = head[:200] + } + switch { + case strings.HasPrefix(ct, "image/"): + return ct + case strings.Contains(ct, "xml") && strings.Contains(strings.ToLower(string(head)), " GETDATE()) + ORDER BY o.B_DATE DESC`, sql.Named("p1", faceID)).Scan(&val) + const notQual = " — если бумага только для квал., нужен отказ M2M08; решает оператор" + if errors.Is(err, sql.ErrNoRows) { + return true, "инвестор НЕ квалифицированный (нет опции «Вхождение в реестр квалифицированных инвесторов» в DEPO)" + notQual + } + if err != nil { + return true, "признак квалифицированного инвестора не прочитан: " + quikErrorDetail(err) + } + mask, e := strconv.ParseInt(strings.TrimSpace(val.String), 10, 64) + if e != nil { + return true, fmt.Sprintf("признак квалифицированного инвестора в DEPO не распознан (%q) — решает оператор", val.String) + } + if mask == 0 { + return true, "инвестор НЕ квалифицированный (опция в DEPO = 0)" + notQual + } + all := mask < 0 || mask&511 == 511 + if all { + return false, "инвестор — квалифицированный по всем видам бумаг (опция DEPO)" + } + var kinds []string + for bit, name := range depoQualKinds { + if mask&(1<0 THEN 1 ELSE 2 END`, + sql.Named("p1", valID), sql.Named("p2", clientID)).Scan(&blockDate, &blockReason, &balAcc, &k) + if er != nil { + return true, "регистра бумаги на счёте нет — блокировок нет" + } + switch { + case blockDate.Valid: + return false, "блокировка с " + blockDate.Time.Format("02.01.2006") + " " + strings.TrimSpace(blockReason.String) + case balAcc.Valid && isBlockingBal(balAcc.Int64) && k.Valid && k.Float64 != 0: + return false, fmt.Sprintf("остаток %.0f на синтетике 98070.xx (BAL_ACC %d) — бумага блокирована", k.Float64, balAcc.Int64) + } + return true, "блокировок нет (BLOCK_DATE пуст, на 98070.05/.07/.08/.09 остатка нет)" +} + +func finishSec(sr *depoSecResult) { + sr.Recommend = "confirm" + for _, c := range sr.Checks { + if !c.OK { + sr.Recommend, sr.RejectCode = "reject", c.Code + return + } + } +} + +func finishResult(res *depoResult) { + confirmN, rejectN := 0, 0 + for _, s := range res.Secs { + if s.Recommend == "confirm" { + confirmN++ + } else { + rejectN++ + } + } + switch { + case rejectN == 0 && confirmN > 0: + res.Recommend, res.Summary = "confirm", "принять" + case confirmN == 0 && rejectN > 0: + res.Recommend, res.Summary = "reject", "отказ "+res.Secs[0].RejectCode + case confirmN > 0 && rejectN > 0: + res.Recommend, res.Summary = "mixed", fmt.Sprintf("принять %d, отказ %d", confirmN, rejectN) + } +} + +// partyChecks — общие проверки инвестора/счёта/ИИС/квал (одинаковы для обеих сверок). +func partyChecks(p depoParty, ser, num, fio string) []depoCheck { + ser = strings.ReplaceAll(strings.TrimSpace(ser), " ", "") + return []depoCheck{ + {Name: "ФИО + документ инвестора", OK: p.faceOK && p.fioMatch, Code: "M2M04", + Detail: pick3(p.faceOK, pick3(p.fioMatch, p.faceName, "паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"), + "паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")}, + {Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02", + Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в OD_CLIENTS", "не проверяется — инвестор не найден"))}, + {Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02", + Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))}, + {Name: "Счёт не ИИС", OK: p.iisOK, Code: "M2M08", Detail: p.iisDet}, + {Name: "Квал-инвестор (если бумага только для квал.)", OK: p.qualOK, Warn: p.qualWarn, Code: "M2M08", Detail: p.qualDet}, + } +} + +// runDepoCheck — сверка ВХОДЯЩЕГО (зачисление). Не отправляет ничего. +func runDepoCheck(it *IncomingTransfer) depoResult { + res := depoResult{Mode: "in"} + if globalRC == nil { + res.Err = "настройки недоступны" + return res + } + fs := globalRC.Snapshot().Fancy + if strings.TrimSpace(fs.Server) == "" { + res.Err = "подключение к MSSQL (Обмен Fancy) не настроено" + return res + } + db, ctx, cancel, errS := depoOpen(fs) + if errS != "" { + res.Err = errS + return res + } + defer cancel() + defer db.Close() + res.Ran = true + + fio := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle) + p := depoResolveParty(ctx, db, fs, it.DocSeries, it.DocNumber, fio) + res.Investor, res.Account = p.faceName, p.accOpen + + for _, s := range it.Securities { + isin := strings.ToUpper(strings.TrimSpace(s.ISIN)) + sr := depoSecResult{ReferenceID: s.ReferenceID, Security: pick(isin, s.SecurityCode)} + ruOK := strings.HasPrefix(isin, "RU") + sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01", + Detail: pick3(ruOK, "допускается к переводам M2M", "не-RU ISIN — к M2M не допускается")}) + valID, vname, found, accepted := depoSecurity(ctx, db, isin) + sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05", + Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")}) + sr.Checks = append(sr.Checks, depoCheck{Name: "Принята на обслуживание (галка)", OK: accepted, Code: "M2M05", + Detail: pick3(!found, "не проверяется — бумаги нет в справочнике", pick3(accepted, "галка стоит (IS_IN=1)", "бумага есть, но галка НЕ стоит (IS_IN≠1)"))}) + sr.Checks = append(sr.Checks, partyChecks(p, it.DocSeries, it.DocNumber, fio)...) + blockOK, blockDet := true, "не проверяется — нет открытого счёта или бумаги" + if p.accOpen != "" && found { + blockOK, blockDet = depoBlocking(ctx, db, valID, p.clientID) + } + sr.Checks = append(sr.Checks, depoCheck{Name: "Нет блокировок по бумаге", OK: blockOK, Code: "M2M06", Detail: blockDet}) + finishSec(&sr) + res.Secs = append(res.Secs, sr) + } + finishResult(&res) + return res +} + +// normFIO — ФИО для сравнения: регистр, лишние пробелы, ё = е. +func normFIO(s string) string { + s = strings.NewReplacer("ё", "е", "Ё", "Е").Replace(s) + return strings.ToUpper(strings.Join(strings.Fields(s), " ")) +} + +// runDepoCheckOut — сверка ИСХОДЯЩЕГО (списание) без счёта и даты рождения клиента (песочница, тесты). +func runDepoCheckOut(last, first, middle, ser, num string, secs []depoOutSec, accountID, sectionID string) depoResult { + return runDepoCheckOutAccount(last, first, middle, ser, num, "", secs, accountID, sectionID, "") +} + +// runDepoCheckOutAccount — сверка ИСХОДЯЩЕГО (списание). accountID/sectionID — место расчётов из +// запроса; ожидаемые значения берём из настроек НРД (наш счёт). clientAccount — торговый счёт +// клиента из заявки (ЛК): должен принадлежать найденному по паспорту инвестору и быть открыт, +// остаток и блокировки считаются по нему. birth — дата рождения клиента из ЛК (ГГГГ-ММ-ДД), +// сверяется с картотекой DEPO. +func runDepoCheckOutAccount(last, first, middle, ser, num, birth string, secs []depoOutSec, accountID, sectionID, clientAccount string) depoResult { + res := depoResult{Mode: "out"} + if globalRC == nil { + res.Err = "настройки недоступны" + return res + } + snap := globalRC.Snapshot() + fs := snap.Fancy + if strings.TrimSpace(fs.Server) == "" { + res.Err = "подключение к MSSQL (Обмен Fancy) не настроено" + return res + } + db, ctx, cancel, errS := depoOpen(fs) + if errS != "" { + res.Err = errS + return res + } + defer cancel() + defer db.Close() + res.Ran = true + + fio := strings.TrimSpace(last + " " + first + " " + middle) + p := depoResolveParty(ctx, db, fs, ser, num, fio) + var accCheck *depoCheck + if want := strings.ToUpper(strings.TrimSpace(clientAccount)); want != "" { + c := depoCheck{Name: "Счёт из заявки принадлежит инвестору и открыт", Code: "M2M02"} + switch { + case !p.faceOK: + c.Detail = "не проверяется — инвестор не найден" + default: + var id, state int64 + var dclose sql.NullTime + err := db.QueryRowContext(ctx, `SELECT TOP 1 ID, ISNULL(STATE,-1), D_CLOSE FROM OD_CLIENTS WHERE FACE=@p1 AND UPPER(NUMBER)=@p2 ORDER BY ID`, + sql.Named("p1", p.faceID), sql.Named("p2", want)).Scan(&id, &state, &dclose) + switch { + case err != nil: + c.Detail = "счёт " + want + " из заявки не принадлежит инвестору" + pick3(p.accAny != "", " (его счёт в DEPO: "+p.accAny+")", "") + p.accOpen, p.clientID = "", 0 + case state != 0 || dclose.Valid: + c.Detail = "счёт " + want + " из заявки принадлежит инвестору, но не открыт (закрыт или STATE≠0)" + p.accOpen, p.clientID = "", 0 + default: + c.OK, c.Detail = true, want+" — совпадает со счётом инвестора в DEPO, открыт" + p.accOpen, p.clientID = want, id + } + } + accCheck = &c + if p.faceOK { // ИИС — по договору именно этого счёта + p.iisOK, p.iisDet = depoAccountIIS(ctx, db, fs, p.clientID) + } + } + var birthCheck *depoCheck + if b := strings.TrimSpace(birth); b != "" { + c := depoCheck{Name: "Дата рождения совпадает с картотекой", Code: "M2M04", OK: true} + want, err := time.Parse("2006-01-02", b[:min(10, len(b))]) + switch { + case err != nil: + c.Warn, c.Detail = true, "дата рождения из заявки «"+b+"» не разобрана — не сверялась" + case !p.faceOK: + c.Detail = "не проверяется — инвестор не найден" + case !p.birth.Valid: + c.Warn, c.Detail = true, "в картотеке DEPO дата рождения не заполнена — не сверялась" + case p.birth.Time.Format("2006-01-02") != want.Format("2006-01-02"): + c.OK, c.Detail = false, "в заявке "+want.Format("02.01.2006")+", в картотеке "+p.birth.Time.Format("02.01.2006") + default: + c.Detail = want.Format("02.01.2006") + " — совпадает" + } + birthCheck = &c + } + res.Investor, res.Account = p.faceName, p.accOpen + + wantAcc := strings.ToUpper(strings.TrimSpace(pick(snap.NSD.AccountID, "HL171004001C"))) + wantSecPfx := strings.ToUpper(strings.TrimSpace(snap.NSD.SectionID)) + if len(wantSecPfx) >= 9 { + wantSecPfx = wantSecPfx[:9] // 36MC04136 — дальше «X» по регламенту + } else { + wantSecPfx = "36MC04136" + } + gotAcc := strings.ToUpper(strings.TrimSpace(accountID)) + gotSec := strings.ToUpper(strings.TrimSpace(sectionID)) + placeOK := gotAcc == wantAcc && strings.HasPrefix(gotSec, wantSecPfx) + + for _, s := range secs { + isin := strings.ToUpper(strings.TrimSpace(s.ISIN)) + sr := depoSecResult{ReferenceID: s.ReferenceID, Security: pick(isin, s.Code)} + ruOK := strings.HasPrefix(isin, "RU") + sr.Checks = append(sr.Checks, depoCheck{Name: "Российский выпуск (RU-ISIN)", OK: ruOK, Code: "M2M01", + Detail: pick3(ruOK, "допускается к переводам M2M", "не-RU ISIN — к M2M не допускается")}) + valID, vname, found, _ := depoSecurity(ctx, db, isin) + sr.Checks = append(sr.Checks, depoCheck{Name: "Бумага есть в справочнике", OK: found, Code: "M2M05", + Detail: pick3(found, vname+" (ID "+fmt.Sprint(valID)+")", "ISIN "+isin+" в справочнике OD_VALUES нет")}) + sr.Checks = append(sr.Checks, partyChecks(p, ser, num, fio)...) + if birthCheck != nil { + sr.Checks = append(sr.Checks, *birthCheck) + } + if accCheck != nil { + sr.Checks = append(sr.Checks, *accCheck) + } + + // остаток K ≥ количеству на торговом счёте (98070.04) + need, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s.Qty), ",", "."), 64) + qtyOK, qtyDet := false, pick3(!found, "не проверяется — бумаги нет в справочнике", "не проверяется — нет открытого счёта клиента") + if p.accOpen != "" && found { + var have float64 + _ = db.QueryRowContext(ctx, + `SELECT ISNULL(SUM(CAST(K AS float)),0) FROM OD_RESTS WHERE VALUE_ID=@p1 AND REG_1=@p2 AND BAL_ACC=@p3`, + sql.Named("p1", valID), sql.Named("p2", p.clientID), sql.Named("p3", depoTradeBalAcc)).Scan(&have) + qtyOK = have >= need && need > 0 + qtyDet = fmt.Sprintf("на торговом счёте %s: %.0f шт., списываем %.0f", p.accOpen, have, need) + if !qtyOK { + qtyDet += " — недостаточно" + } + } + sr.Checks = append(sr.Checks, depoCheck{Name: "Остаток ≥ количеству (98070.04)", OK: qtyOK, Code: "QTY", Detail: qtyDet}) + + // место хранения строго: наш счёт в НРД / раздел с началом 36MC04136 + sr.Checks = append(sr.Checks, depoCheck{Name: "Место хранения строго наш торговый счёт", OK: placeOK, Code: "PLACE", + Detail: pick3(placeOK, gotAcc+" / "+gotSec+" — совпадает", "в запросе "+pick(gotAcc, "—")+" / "+pick(gotSec, "—")+", ожидается "+wantAcc+" / "+wantSecPfx+"…")}) + + blockOK, blockDet := true, pick3(!found, "не проверяется — бумаги нет в справочнике", "не проверяется — нет открытого счёта клиента") + if p.accOpen != "" && found { + blockOK, blockDet = depoBlocking(ctx, db, valID, p.clientID) + } + sr.Checks = append(sr.Checks, depoCheck{Name: "Нет блокировки на синтетике 98070.xx", OK: blockOK, Code: "M2M06", Detail: blockDet}) + sr.Checks = append(sr.Checks, depoCheck{Name: "Задолженность по комиссиям", OK: true, Warn: true, Code: "", + Detail: "отложено по регламенту — информация у бэк-офиса"}) + finishSec(&sr) + res.Secs = append(res.Secs, sr) + } + finishResult(&res) + return res +} + +func isBlockingBal(id int64) bool { + for _, b := range depoBlockingBalAccs { + if b == id { + return true + } + } + return false +} + +func pick(a, b string) string { + if a != "" { + return a + } + return b +} + +func pick3(cond bool, yes, no string) string { + if cond { + return yes + } + return no +} diff --git a/internal/lkgateway/depo_check_test.go b/internal/lkgateway/depo_check_test.go new file mode 100644 index 0000000..ac5d1f3 --- /dev/null +++ b/internal/lkgateway/depo_check_test.go @@ -0,0 +1,16 @@ +package lkgateway + +import "testing" + +func TestPartyChecksFIO(t *testing.T) { + p := depoParty{faceOK: true, faceName: "Сёмин Пётр Ильич"} + p.fioMatch = normFIO(p.faceName) == normFIO("СЕМИН петр ильич") + if c := partyChecks(p, "4500", "123456", "СЕМИН петр ильич")[0]; !c.OK { + t.Errorf("регистр, пробелы и ё не должны давать расхождение: %+v", c) + } + p.fioMatch = normFIO(p.faceName) == normFIO("Сёмин Павел Ильич") + c := partyChecks(p, "4500", "123456", "Сёмин Павел Ильич")[0] + if c.OK || c.Code != "M2M04" { + t.Errorf("паспорт совпал, ФИО нет — ожидали отказ M2M04: %+v", c) + } +} diff --git a/internal/lkgateway/depo_live_test.go b/internal/lkgateway/depo_live_test.go new file mode 100644 index 0000000..34735a6 --- /dev/null +++ b/internal/lkgateway/depo_live_test.go @@ -0,0 +1,79 @@ +//go:build live + +package lkgateway + +// Живой прогон обмена с DEPO через трансферные таблицы (наша копия BJ_DEPO_DATA). +// go test -c -tags live -o depo_live.test ./internal/lkgateway/ ; запуск от bj. + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" +) + +func TestDepoLiveEvents(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + guid := fmt.Sprintf("%08X-0000-4000-8000-%012X", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF) + t.Logf("GUID %s", guid) + lines := []depoLine{{Ref: "M2MTEST0001", ISIN: "RU000A107UL4", Code: "RU000A107UL4", Amount: 1, Place: "36MC0413600000F00"}} + ok := depoUpsertOrder(rc, guid, "IN", "MC0034900000", "Погонщикова Виктория Олеговна", "4500 000000", "4500", "000000", lines, "ТЕСТ bj: проверка трансферных таблиц") + t.Logf("FTV_M2M создано: %v", ok) + depoEvent(rc, guid, "", "BJ_RECEIVED", "", "ТЕСТ: входящий получен") + depoEvent(rc, guid, "M2MTEST0001", "BJ_DECISION_REJECT", "M2M04", "ТЕСТ: отказ") + depoEvent(rc, guid, "M2MTEST0001", "BJ_DECISION_REJECT", "M2M04", "ТЕСТ: повтор (не должен записаться)") + + db, err := depoEventsDB(rc) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + var n int + db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid)).Scan(&n) + t.Logf("событий BJ_* записано: %d (ожидаем 2)", n) + var lineN int + db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_LINES WHERE GUID=@g", sql.Named("g", guid)).Scan(&lineN) + var did sql.NullInt64 + db.QueryRowContext(ctx, "SELECT DEPONENT_ID FROM dbo.FTV_M2M WHERE GUID=@g", sql.Named("g", guid)).Scan(&did) + t.Logf("строк: %d, DEPONENT_ID (SELF_ID по паспорту): %v", lineN, did) + + // джоба bj2depo на SQL002 обрабатывает за секунды: ждём DOC + for i := 0; i < 12; i++ { + time.Sleep(5 * time.Second) + depoPollEvents(ctx, rc) + if l := globalDepoLinks.Get(guid); l != nil && l.DocNo != "" { + t.Logf("джоба создала поручение DEPO: DOC=%s (через %d с)", l.DocNo, (i+1)*5) + break + } + } + // имитация ответа джобы bj2depo + if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'DEPO_ORDER_CREATED', GETDATE(), '75069999')", sql.Named("g", guid)); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, 'M2MTEST0001', 'TODO', 'DEPO_ERROR', GETDATE(), N'ТЕСТ: бумага не найдена')", sql.Named("g", guid)); err != nil { + t.Fatal(err) + } + depoPollEvents(ctx, rc) + l := globalDepoLinks.Get(guid) + if l == nil { + t.Fatal("связка DEPO не создана") + } + t.Logf("после чтения: DocNo=%s errors=%v last=%s", l.DocNo, l.Errors, l.LastEvent) + var todo, done int + db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND EVENT_TYPE LIKE 'DEPO[_]%' AND STATE='TODO'", sql.Named("g", guid)).Scan(&todo) + db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND EVENT_TYPE LIKE 'DEPO[_]%' AND STATE='SUCCESS'", sql.Named("g", guid)).Scan(&done) + t.Logf("DEPO-события: TODO=%d SUCCESS=%d (ожидаем 0/2)", todo, done) + + // уборка тестовых строк + for _, tbl := range []string{"FTV_M2M_EVENTS", "FTV_M2M_LINES", "FTV_M2M"} { + if _, err := db.ExecContext(ctx, "DELETE FROM dbo."+tbl+" WHERE GUID=@g", sql.Named("g", guid)); err != nil { + t.Logf("уборка %s: %v", tbl, err) + } + } + t.Log("тестовые строки удалены") +} diff --git a/internal/lkgateway/depo_sim.go b/internal/lkgateway/depo_sim.go new file mode 100644 index 0000000..27713fb --- /dev/null +++ b/internal/lkgateway/depo_sim.go @@ -0,0 +1,127 @@ +package lkgateway + +// depo_sim.go — песочница сверки с DEPO («имитация ответа Fancy»). +// Два режима: «зачисление» (входящий) и «списание» (исходящий). Оператор берёт +// реального клиента и бумагу из DEPO, меняет любые поля и нажимает «Проверить» — +// отрабатывают те же runDepoCheck / runDepoCheckOut, что и по настоящим +// переводам. В НРД и контрагенту ничего не отправляется. + +import ( + "context" + "net/http" + "strings" + "time" +) + +type depoSimClient struct{ Name, Ser, Num, Account string } +type depoSimSec struct{ ISIN, Name string } + +// depoSimLists — реальные клиенты с открытым торговым счётом и бумаги на +// обслуживании из DEPO (для выпадающих списков). Ошибки глушим — списки +// вспомогательные, форму можно заполнить руками. +func depoSimLists() (clients []depoSimClient, secs []depoSimSec) { + if globalRC == nil { + return + } + fs := globalRC.Snapshot().Fancy + if strings.TrimSpace(fs.Server) == "" { + return + } + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + return + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + rows, err := db.QueryContext(ctx, + `SELECT TOP 25 ISNULL(f.NAME,''), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''), ISNULL(c.NUMBER,'') + FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID + WHERE c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL + AND LEN(ISNULL(f.DOC_SER,''))>0 AND LEN(ISNULL(f.DOC_NUM,''))>0 + ORDER BY f.NAME`) + if err == nil { + defer rows.Close() + seen := map[string]bool{} + for rows.Next() { + var c depoSimClient + if rows.Scan(&c.Name, &c.Ser, &c.Num, &c.Account) == nil && !seen[c.Name] { + seen[c.Name] = true + c.Ser = strings.ReplaceAll(c.Ser, " ", "") + clients = append(clients, c) + } + } + } + rows2, err := db.QueryContext(ctx, + `SELECT TOP 20 ISNULL(ISIN,''), ISNULL(NAME,'') FROM OD_VALUES + WHERE V_TYPE=2 AND IS_IN=1 AND ISIN LIKE 'RU%' ORDER BY NAME`) + if err == nil { + defer rows2.Close() + for rows2.Next() { + var s depoSimSec + if rows2.Scan(&s.ISIN, &s.Name) == nil && s.ISIN != "" { + secs = append(secs, s) + } + } + } + return +} + +// registerDepoSim вешает /admin/m2m-testing/depo-sim (GET — форма, POST — проверка). +func registerDepoSim(mux *http.ServeMux, a *admin) { + mux.HandleFunc("/admin/m2m-testing/depo-sim", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + v := func(k, def string) string { + if s := strings.TrimSpace(r.FormValue(k)); s != "" { + return s + } + return def + } + defAcc, defSec := "HL171004001C", "36MC0413600000F00" + if globalRC != nil { + nsd := globalRC.Snapshot().NSD + defAcc, defSec = pick(nsd.AccountID, defAcc), pick(nsd.SectionID, defSec) + } + form := struct { + Mode, Last, First, Middle, Ser, Num, ISIN, Code, Qty, Account, Section string + }{ + Mode: v("mode", "in"), + Last: v("last", "Силаев"), First: v("first", "Михаил"), Middle: v("middle", "Иванович"), + Ser: v("ser", "4505"), Num: v("num", "620343"), + ISIN: strings.ToUpper(v("isin", "RU0009029540")), Code: v("code", ""), Qty: v("qty", "10"), + Account: v("account", defAcc), Section: v("section", defSec), + } + if form.Mode != "out" { + form.Mode = "in" + } + var res *depoResult + if r.Method == http.MethodPost { + var rr depoResult + if form.Mode == "out" { + rr = runDepoCheckOut(form.Last, form.First, form.Middle, form.Ser, form.Num, + []depoOutSec{{ReferenceID: "sim-1", ISIN: form.ISIN, Code: pick(form.Code, form.ISIN), Qty: form.Qty}}, + form.Account, form.Section) + } else { + it := &IncomingTransfer{ + GUID: "SIMULATION", SenderCode: "SIM", ReceivedAt: time.Now(), + InvestorLast: form.Last, InvestorFirst: form.First, InvestorMiddle: form.Middle, + DocType: "21", DocSeries: form.Ser, DocNumber: form.Num, + Securities: []IncomingSecurity{{ReferenceID: "sim-1", ISIN: form.ISIN, SecurityCode: pick(form.Code, form.ISIN), Quantity: form.Qty}}, + } + rr = runDepoCheck(it) + } + res = &rr + } + clients, secs := depoSimLists() + data := struct { + page + Form any + Result *depoResult + Clients []depoSimClient + Secs []depoSimSec + Posted bool + }{nowPage("Имитация сверки с DEPO", "m2mtest"), form, res, clients, secs, r.Method == http.MethodPost} + render(w, a.depoSim, data) + }) +} diff --git a/internal/lkgateway/depodb.go b/internal/lkgateway/depodb.go new file mode 100644 index 0000000..fc20226 --- /dev/null +++ b/internal/lkgateway/depodb.go @@ -0,0 +1,220 @@ +package lkgateway + +import ( + "context" + "fmt" + "strings" + "time" +) + +// Чтение РЕАЛЬНЫХ портфелей клиентов из депозитарной базы Fancy (BJ_DEPO_DATA). +// Только SELECT — боевая база учёта, ничего не меняем. +// +// Найденная цепочка связи: +// +// OD_CLIENTS_PORTS.PORT_NUM договор клиента («БР-1001 Гравс Геннадий») +// ↓ CLIENT +// OD_CLIENTS.ID счёт депо клиента (В00000957 / ВТНКЦ00000957) +// ↓ = OD_RESTS.REG_1 ← ключ связи остатков со счётом +// OD_RESTS K = количество, BAL_ACC (836 рос. / 318 иностр.), +// BLOCK_DATE/BLOCK_REASON = блокировка +// ↓ VALUE_ID +// OD_VALUES бумага: NAME + ISIN (V_TYPE=2 — ценные бумаги) +// +// Важно: numeric-колонки читаем через CAST(... AS float) — иначе драйвер отдаёт 0. + +// DepoPosition — позиция клиента по данным депозитарного учёта. +type DepoPosition struct { + Contract string // номер договора (PORT_NUM) + ClientName string // ФИО из договора + AccountNum string // счёт депо (OD_CLIENTS.NUMBER) + ISIN string // + SecurityName string // + Quantity float64 // остаток (OD_RESTS.K) + Blocked float64 // из них заблокировано (по BLOCK_REASON) + BlockReason string // + BalAcc int // балансовый счёт (836 — рос., 318 — иностр.) +} + +// depoContractExists проверяет, есть ли такой договор в депозитарии. +func depoContractExists(ctx context.Context, s FancyMSSQLSettings, contract string) (string, bool) { + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return "", false + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + var full string + err = conn.QueryRowContext(cctx, + "SELECT TOP 1 PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM=@p1 OR PORT_NUM LIKE @p2", + contract, contract+" %").Scan(&full) + if err != nil { + return "", false + } + return full, true +} + +// depoClientPositions читает реальный портфель клиента по номеру договора. +// contract — «БР-1001» либо полная строка «БР-1001 Гравс Геннадий». +func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract string) ([]DepoPosition, error) { + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() + + q := `SELECT p.PORT_NUM, ISNULL(c.NUMBER,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), + CAST(rst.K AS float), ISNULL(rst.BLOCK_REASON,''), ISNULL(rst.BAL_ACC,0) + FROM OD_RESTS rst + JOIN OD_CLIENTS c ON c.ID = rst.REG_1 + JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID + JOIN OD_VALUES v ON v.ID = rst.VALUE_ID AND v.V_TYPE = 2 + WHERE (p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2) + AND CAST(rst.K AS float) <> 0 + ORDER BY v.NAME` + rows, err := conn.QueryContext(cctx, q, contract, contract+" %") + if err != nil { + return nil, err + } + defer rows.Close() + var out []DepoPosition + for rows.Next() { + var p DepoPosition + if err := rows.Scan(&p.Contract, &p.AccountNum, &p.ISIN, &p.SecurityName, + &p.Quantity, &p.BlockReason, &p.BalAcc); err != nil { + return out, err + } + // ФИО зашито в номер договора: «БР-1001 Гравс Геннадий» + if i := strings.Index(p.Contract, " "); i > 0 { + p.ClientName = strings.TrimSpace(p.Contract[i:]) + } + if p.BlockReason != "" { + p.Blocked = p.Quantity + } + out = append(out, p) + } + return out, rows.Err() +} + +// depoPositionsAsClient переводит позиции депозитария в вид, который отдаётся в +// Личный кабинет (агрегируя одинаковые бумаги по разным счетам клиента). +func depoPositionsAsClient(pos []DepoPosition) []ClientPosition { + type agg struct { + p ClientPosition + accts map[string]bool + } + byISIN := map[string]*agg{} + var order []string + for _, d := range pos { + key := strings.ToUpper(d.ISIN) + a, ok := byISIN[key] + if !ok { + a = &agg{p: ClientPosition{ + ISIN: d.ISIN, + SecurityCode: d.ISIN, + SecurityName: d.SecurityName, + ClientName: d.ClientName, + }, accts: map[string]bool{}} + byISIN[key] = a + order = append(order, key) + } + a.p.Quantity += d.Quantity + a.p.Blocked += d.Blocked + if d.AccountNum != "" && !a.accts[d.AccountNum] { + a.accts[d.AccountNum] = true + if a.p.AccountID == "" { + a.p.AccountID = d.AccountNum + } else { + a.p.AccountID += ", " + d.AccountNum + } + } + } + out := make([]ClientPosition, 0, len(order)) + for _, k := range order { + a := byISIN[k] + a.p.Available = a.p.Quantity - a.p.Blocked + a.p.UpdatedAt = time.Now().Format("02.01.2006 15:04") + out = append(out, a.p) + } + // Иностранные бумаги видны в портфеле, но к переводу недоступны. + return markTransferable(out) +} + +// DepoContract — реальный клиент депозитария для списка в админке ЛК. +type DepoContract struct { + Contract string `json:"contract"` // «БР-1001 Гравс Геннадий» + ClientName string `json:"name"` // ФИО (если есть в номере договора) + Securities int `json:"securities"` // сколько бумаг с ненулевым остатком +} + +// depoListContracts — реальные клиенты депозитария с ненулевыми остатками. +// search — часть номера договора или ФИО (пусто = первые limit по алфавиту). +func depoListContracts(ctx context.Context, s FancyMSSQLSettings, search string, limit int) ([]DepoContract, error) { + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if limit <= 0 || limit > 500 { + limit = 100 + } + q := fmt.Sprintf(`SELECT TOP (%d) p.PORT_NUM, COUNT(DISTINCT rst.VALUE_ID) + FROM OD_CLIENTS_PORTS p + JOIN OD_CLIENTS c ON c.ID = p.CLIENT + JOIN OD_RESTS rst ON rst.REG_1 = c.ID + JOIN OD_VALUES v ON v.ID = rst.VALUE_ID AND v.V_TYPE = 2 + WHERE CAST(rst.K AS float) <> 0 AND (@p1 = '' OR p.PORT_NUM LIKE @p2) + GROUP BY p.PORT_NUM ORDER BY p.PORT_NUM`, limit) + rows, err := conn.QueryContext(cctx, q, search, "%"+search+"%") + if err != nil { + return nil, err + } + defer rows.Close() + var out []DepoContract + for rows.Next() { + var c DepoContract + if err := rows.Scan(&c.Contract, &c.Securities); err != nil { + return out, err + } + if i := strings.Index(c.Contract, " "); i > 0 { + c.ClientName = strings.TrimSpace(c.Contract[i:]) + } + out = append(out, c) + } + return out, rows.Err() +} + +// depoFindContracts ищет договоры клиентов по части номера/ФИО (для админки). +func depoFindContracts(ctx context.Context, s FancyMSSQLSettings, like string, limit int) ([]string, error) { + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := conn.QueryContext(cctx, fmt.Sprintf( + "SELECT DISTINCT TOP (%d) PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM LIKE @p1 ORDER BY PORT_NUM", limit), + "%"+like+"%") + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return out, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/internal/lkgateway/depoevents.go b/internal/lkgateway/depoevents.go new file mode 100644 index 0000000..0dc20fd --- /dev/null +++ b/internal/lkgateway/depoevents.go @@ -0,0 +1,1036 @@ +package lkgateway + +// Обмен с DEPO (Fancy) через трансферные таблицы в нашей копии базы +// (BJ_DEPO_DATA): FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS / FTV_M2M_HANDBOOK. +// Согласовано с программистом Fancy 09–10.09.2026 (страница /admin/help/fancy-events): +// - bj пишет поручения и события BJ_*; джоба bj2depo создаёт сущности DEPO и +// пишет в ответ только DEPO_ORDER_CREATED / DEPO_ERROR; +// - справочник участников — TRUNCATE + полная перезапись при каждом получении; +// - STATE: TODO при записи → SUCCESS/ERROR после обработки получателем. + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +const depoLinkFile = "/var/lib/bj/.bj/depo-events.json" + +var uuidRe = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// DepoLink — что DEPO ответила по переводу (для карточек). +type DepoLink struct { + GUID string `json:"guid"` + DocNo string `json:"doc_no,omitempty"` // номер поручения в DEPO (из DEPO_ORDER_CREATED) + DocAt time.Time `json:"doc_at,omitempty"` + DocState string `json:"doc_state,omitempty"` // состояние документа в DEPO (OD_DOCS.STATE → название) + DocStateAt time.Time `json:"doc_state_at,omitempty"` + Errors []string `json:"errors,omitempty"` // DEPO_ERROR + LastEvent string `json:"last_event,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type depoLinkStore struct { + mu sync.Mutex + items map[string]*DepoLink +} + +var globalDepoLinks = loadDepoLinks() + +func loadDepoLinks() *depoLinkStore { + s := &depoLinkStore{items: map[string]*DepoLink{}} + if b, err := os.ReadFile(depoLinkFile); err == nil { + var list []*DepoLink + if json.Unmarshal(b, &list) == nil { + for _, l := range list { + s.items[strings.ToUpper(l.GUID)] = l + } + } + } + return s +} + +func (s *depoLinkStore) Get(guid string) *DepoLink { + s.mu.Lock() + defer s.mu.Unlock() + if l, ok := s.items[strings.ToUpper(guid)]; ok { + cp := *l + return &cp + } + return nil +} + +func (s *depoLinkStore) update(guid string, fn func(l *DepoLink)) { + s.mu.Lock() + defer s.mu.Unlock() + k := strings.ToUpper(guid) + l, ok := s.items[k] + if !ok { + l = &DepoLink{GUID: k} + s.items[k] = l + } + fn(l) + l.UpdatedAt = time.Now() + list := make([]*DepoLink, 0, len(s.items)) + for _, x := range s.items { + list = append(list, x) + } + b, _ := json.MarshalIndent(list, "", " ") + _ = os.MkdirAll(filepath.Dir(depoLinkFile), 0o755) + tmp := depoLinkFile + ".tmp" + if os.WriteFile(tmp, b, 0o640) == nil { + _ = os.Rename(tmp, depoLinkFile) + } +} + +// ---- подключение и проверки ---- + +func depoEventsDB(rc *RuntimeConfig) (*sql.DB, error) { + if rc == nil { + return nil, fmt.Errorf("нет конфигурации") + } + fs := rc.Snapshot().Fancy + if strings.TrimSpace(fs.Server) == "" { + return nil, fmt.Errorf("сервер Fancy не настроен") + } + return fancyOpen(fs, fs.DepoDB()) +} + +func depoHasColumn(ctx context.Context, db *sql.DB, table, col string) bool { + var n int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME=@t AND COLUMN_NAME=@c", sql.Named("t", table), sql.Named("c", col)).Scan(&n) + return n > 0 +} + +func depoTableExists(ctx context.Context, db *sql.DB, name string) bool { + var id sql.NullInt64 + if err := db.QueryRowContext(ctx, "SELECT OBJECT_ID('dbo."+name+"')").Scan(&id); err != nil { + return false + } + return id.Valid +} + +var depoTablesWarned time.Time + +// depoEvent — одна строка FTV_M2M_EVENTS (идемпотентно по GUID+REFERENCE+EVENT_TYPE), дата = сейчас. +func depoEvent(rc *RuntimeConfig, guid, ref, typ, codes, comment string) { + depoEventAt(rc, time.Time{}, guid, ref, typ, codes, comment) +} + +// depoEventAt — то же с явной датой события (для выгрузки истории). +func depoEventAt(rc *RuntimeConfig, at time.Time, guid, ref, typ, codes, comment string) { + guid = strings.ToUpper(strings.TrimSpace(guid)) + if !uuidRe.MatchString(guid) { + return // тестовые/служебные идентификаторы в DEPO не отдаём + } + db, err := depoEventsDB(rc) + if err != nil { + return + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") { + if time.Since(depoTablesWarned) > time.Hour { + depoTablesWarned = time.Now() + log.Printf("depo-events: таблицы FTV_M2M_EVENTS нет в %s — события не пишутся", rc.Snapshot().Fancy.DepoDB()) + } + return + } + if len([]rune(comment)) > 4000 { + comment = string([]rune(comment)[:4000]) + } + var n int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t", + sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&n) + if n > 0 { + return + } + // Параметр даты — типизированный NULL: при нетипизированном nil драйвер шлёт nvarchar(1), + // ISNULL берёт тип первого аргумента и GETDATE() обрезается до одного символа. + evDate := sql.NullTime{Time: at, Valid: !at.IsZero()} + _, err = db.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT) + VALUES (@g, @r, 'TODO', @t, COALESCE(@at, GETDATE()), @c, @cm)`, + sql.Named("g", guid), sql.Named("r", nullStr(ref)), sql.Named("t", typ), sql.Named("at", evDate), sql.Named("c", nullStr(codes)), sql.Named("cm", nullStr(comment))) + if err != nil { + log.Printf("depo-events: %s %s/%s: %v", typ, shortGUID(guid), ref, err) + return + } + log.Printf("depo-events: %s %s %s %s", typ, shortGUID(guid), ref, codes) +} + +type depoLine struct { + Ref string + ISIN string + Code string + Amount float64 + Place string +} + +// depoUpsertOrder — шапка FTV_M2M + строки FTV_M2M_LINES (одна транзакция, идемпотентно по GUID). +func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, deponentDoc string, ser, num string, lines []depoLine, comment string) bool { + guid = strings.ToUpper(strings.TrimSpace(guid)) + if !uuidRe.MatchString(guid) { + return false + } + db, err := depoEventsDB(rc) + if err != nil { + return false + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if !depoTableExists(ctx, db, "FTV_M2M") || !depoTableExists(ctx, db, "FTV_M2M_LINES") { + return false + } + var n int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM dbo.FTV_M2M WHERE GUID=@g", sql.Named("g", guid)).Scan(&n) + if n > 0 { + return true + } + // идентификаторы DEPO: лицо (SELF_ID) по паспорту, бумага (OD_VALUES.ID) по ISIN + var deponentID sql.NullInt64 + serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "") + if serN != "" && strings.TrimSpace(num) != "" { + _ = db.QueryRowContext(ctx, "SELECT TOP 1 SELF_ID FROM OD_FACES WHERE REPLACE(DOC_SER,' ','')=@s AND DOC_NUM=@n ORDER BY ID", sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&deponentID) + } + hasDeponentID := depoHasColumn(ctx, db, "FTV_M2M", "DEPONENT_ID") + tx, err := db.BeginTx(ctx, nil) + if err != nil { + log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err) + return false + } + head := `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @cm)` + if hasDeponentID { + head = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT) VALUES (@g, @d, @cp, @nm, @doc, @did, @cm)` + } + if _, err := tx.ExecContext(ctx, head, + sql.Named("g", guid), sql.Named("d", direction), sql.Named("cp", nullStr(cpCode)), sql.Named("nm", nullStr(deponentName)), sql.Named("doc", nullStr(deponentDoc)), + sql.Named("did", deponentID), sql.Named("cm", nullStr(comment))); err != nil { + tx.Rollback() + log.Printf("depo-events: FTV_M2M %s: %v", shortGUID(guid), err) + return false + } + // SHARE (ID бумаги в DEPO) — заполняем, если колонка есть (джоба может подставить сама) + hasShare := depoHasColumn(ctx, db, "FTV_M2M_LINES", "SHARE") + for _, ln := range lines { + var share sql.NullInt64 + if ln.ISIN != "" && hasShare { + _ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", ln.ISIN)).Scan(&share) + } + q := `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @a, @p, '')` + if hasShare { + q = `INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES (@g, @r, @i, @c, @sh, @a, @p, '')` + } + if _, err := tx.ExecContext(ctx, q, + sql.Named("g", guid), sql.Named("r", ln.Ref), sql.Named("i", nullStr(ln.ISIN)), sql.Named("c", nullStr(ln.Code)), sql.Named("sh", share), sql.Named("a", ln.Amount), sql.Named("p", nullStr(ln.Place))); err != nil { + tx.Rollback() + log.Printf("depo-events: FTV_M2M_LINES %s/%s: %v", shortGUID(guid), ln.Ref, err) + return false + } + } + if err := tx.Commit(); err != nil { + log.Printf("depo-events: commit %s: %v", shortGUID(guid), err) + return false + } + log.Printf("depo-events: FTV_M2M %s %s создано (%d строк)", direction, shortGUID(guid), len(lines)) + return true +} + +// handbookCodeByINN — MC-код депозитария по ИНН из сохранённого справочника. +func handbookCodeByINN(inn string) string { + raw, err := os.ReadFile(handbookFile) + if err != nil { + return "" + } + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + if json.Unmarshal(raw, &hb) != nil { + return "" + } + best := "" + for _, p := range hb.Participants { + if p.INN == strings.TrimSpace(inn) && p.Code != "" { + if p.Kind == "depo" { + return p.Code + } + if best == "" { + best = p.Code + } + } + } + return best +} + +func parseQty(s string) float64 { + f, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(s), ",", "."), 64) + return f +} + +// ---- события по этапам ---- + +// входящий M2MTR получен → шапка IN + строки + BJ_RECEIVED +func depoOnIncomingReceived(rc *RuntimeConfig, it *IncomingTransfer) { + if rc == nil || it == nil { + return + } + place := rc.Snapshot().NSD.SectionID + var lines []depoLine + for _, s := range it.Securities { + lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place}) + } + name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " ")) + doc := strings.TrimSpace(it.DocSeries + " " + it.DocNumber) + if depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, doc, it.DocSeries, it.DocNumber, lines, "входящий перевод M2M от "+it.SenderCode) { + depoEvent(rc, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен, ждёт решения") + } +} + +// наш M2MTR ушёл в НРД → шапка OUT + строки + BJ_SENT +func depoOnOutgoingSent(rc *RuntimeConfig, req *m2m.M2MTransferRequest) { + if rc == nil || req == nil { + return + } + inv := req.Data.InvestorInformation + ser := "" + if inv.IdentityDocument.DocumentSeries != nil { + ser = string(*inv.IdentityDocument.DocumentSeries) + } + num := string(inv.IdentityDocument.DocumentNumber) + place := rc.Snapshot().NSD.SectionID + var lines []depoLine + for _, s := range req.Data.TransferredSecurities.Securities { + lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place}) + } + inn := string(req.Data.ReceivingDepository.INN) + cp := handbookCodeByINN(inn) + name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " ")) + guid := string(req.Header.GUID) + if depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "исходящий перевод M2M, получатель ИНН "+inn) { + depoEvent(rc, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД") + } +} + +// наше решение по входящему (M2MTD) +func depoOnIncomingDecision(rc *RuntimeConfig, it *IncomingTransfer, decisions []SecurityDecision) { + if rc == nil || it == nil { + return + } + for _, d := range decisions { + if d.Confirm { + depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём бумаги подтверждён контрагенту") + } else { + depoEvent(rc, it.GUID, d.ReferenceID, "BJ_DECISION_REJECT", d.RejectCode, "отказ контрагенту: "+m2mCodeLabel(d.RejectCode)) + } + } +} + +// ответ контрагента по нашему исходящему +func depoOnCounterpartyDecision(rc *RuntimeConfig, decision *m2m.M2MTransferDecision) { + if rc == nil || decision == nil { + return + } + guid := string(decision.Header.GUID) + for _, sec := range decision.Data.Securities { + ref := string(sec.ReferenceID) + if sec.TransferDecision.Rejection != nil { + codes := strings.Join(sec.TransferDecision.Rejection.Codes, ";") + depoEvent(rc, guid, ref, "BJ_CP_REJECT", codes, "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes)) + } else if sec.TransferDecision.Confirmation != nil { + depoEvent(rc, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил") + } + } +} + +func depoOnServiceError(rc *RuntimeConfig, guid, codes, reason string) { + depoEvent(rc, guid, "", "BJ_SERVICE_ERROR", strings.ReplaceAll(strings.TrimSpace(codes), " ", ";"), "отклонено сервисом MOEX МОСТ: "+reason) +} + +func depoOnOrderSent(rc *RuntimeConfig, guid string, contrDIDs []string, form string) { + depoEvent(rc, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение "+form+" в НРД, CONTR_D_ID "+strings.Join(contrDIDs, ", ")) +} + +// результат по депо-поручению: accepted | rejected | late | expired +func depoOnOrderResult(rc *RuntimeConfig, guid, kind, text string) { + switch kind { + case "accepted": + depoEvent(rc, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)") + depoEvent(rc, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + case "rejected": + depoEvent(rc, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text) + case "late": + depoEvent(rc, guid, "", "BJ_ORDER_LATE", "", "отчёт MS010 не получен за 5 минут") + case "expired": + depoEvent(rc, guid, "", "BJ_CANCELLED", "", "истёк срок 30 дней без исполнения — перевод считается отменённым") + } +} + +func depoOnQuik(rc *RuntimeConfig, guid, isin, evt, instrNo string) { + typ := map[string]string{"register": "BJ_QUIK_BLOCKED", "deny": "BJ_QUIK_RELEASED", "sign": "BJ_QUIK_SIGNED", "deposit": "BJ_QUIK_DEPOSITED"}[evt] + if typ == "" { + return + } + depoEvent(rc, guid, "", typ, "", "QUIK МНП: поручение №"+instrNo+", "+isin) +} + +// ---- чтение событий DEPO ---- + +func depoPollEvents(ctx context.Context, rc *RuntimeConfig) { + db, err := depoEventsDB(rc) + if err != nil { + return + } + defer db.Close() + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if !depoTableExists(cctx, db, "FTV_M2M_EVENTS") { + return + } + docCol := "NULL" + if depoHasColumn(cctx, db, "FTV_M2M", "DOC") { + docCol = "(SELECT TOP 1 CAST(m.DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)" + } else if depoHasColumn(cctx, db, "FTV_M2M", "FANSY_DOC") { + docCol = "(SELECT TOP 1 CAST(m.FANSY_DOC AS varchar(32)) FROM dbo.FTV_M2M m WHERE m.GUID=e.GUID)" + } + depoPollOrders(cctx, db, rc) + depoAlertEventFailures(cctx, db, rc) + rows, err := db.QueryContext(cctx, `SELECT TOP 100 e.ID, CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), e.EVENT_TYPE, ISNULL(e.COMMENT,''), ISNULL(e.M2M_REJECT_CODE,''), + ISNULL(`+docCol+`,'') + FROM dbo.FTV_M2M_EVENTS e WHERE e.EVENT_TYPE LIKE 'DEPO[_]%' AND ISNULL(e.STATE,'TODO')='TODO' ORDER BY e.ID`) + if err != nil { + log.Printf("depo-events: чтение: %v", err) + return + } + type ev struct { + id int64 + guid, ref, typ, comment, codes, doc string + } + var list []ev + for rows.Next() { + var e ev + if rows.Scan(&e.id, &e.guid, &e.ref, &e.typ, &e.comment, &e.codes, &e.doc) == nil { + list = append(list, e) + } + } + rows.Close() + for _, e := range list { + state, note := "SUCCESS", "применено в bj" + switch e.typ { + case "DEPO_ORDER_CREATED": + doc := strings.TrimSpace(e.comment) + if e.doc != "" && e.doc != "0" { + doc = e.doc + } + globalDepoLinks.update(e.guid, func(l *DepoLink) { l.DocNo, l.DocAt, l.LastEvent = doc, time.Now(), e.typ }) + log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(e.guid), doc) + case "DEPO_ERROR": + globalDepoLinks.update(e.guid, func(l *DepoLink) { + l.Errors = append(l.Errors, time.Now().Format("02.01 15:04")+" "+e.comment) + l.LastEvent = e.typ + }) + raiseSystemAlert(rc, "DEPO: ошибка обработки перевода "+shortGUID(e.guid), e.comment) + default: + state, note = "ERROR", "тип события не поддерживается bj" + } + _, _ = db.ExecContext(cctx, "UPDATE dbo.FTV_M2M_EVENTS SET STATE=@s, TimeBeg=ISNULL(TimeBeg,GETDATE()), TimeEnd=GETDATE(), COMMENT=ISNULL(COMMENT,'')+@n WHERE ID=@id", + sql.Named("s", state), sql.Named("n", " | bj: "+note), sql.Named("id", e.id)) + } +} + +// depoPollOrders — джоба bj2depo ставит номер поручения DEPO прямо в FTV_M2M.DOC (и DOL по строкам), +// а ошибки — STATE='ERROR' + COMMENT. Забираем это в связки без отдельных событий. +func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) { + if !depoTableExists(ctx, db, "FTV_M2M") { + return + } + docCol := "NULL" + if depoHasColumn(ctx, db, "FTV_M2M", "DOC") { + docCol = "CAST(m.DOC AS varchar(32))" + } else if depoHasColumn(ctx, db, "FTV_M2M", "FANSY_DOC") { + docCol = "CAST(m.FANSY_DOC AS varchar(32))" + } + rows, err := db.QueryContext(ctx, `SELECT TOP 200 CAST(m.GUID AS varchar(36)), ISNULL(m.STATE,''), ISNULL(`+docCol+`,''), ISNULL(m.COMMENT,''), + ISNULL((SELECT TOP 1 ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID=m.GUID AND ISNULL(l.STATE,'')='ERROR'),''), + ISNULL((SELECT TOP 1 ISNULL(st.NAME,'') FROM OD_DOCS d LEFT JOIN OD_DOC_STATES st ON st.ID=d.STATE AND st.D_CAT=0 WHERE CAST(d.ID AS varchar(32))=`+docCol+`),''), + (SELECT TOP 1 d.PROC_DATE FROM OD_DOCS d WHERE CAST(d.ID AS varchar(32))=`+docCol+`) + FROM dbo.FTV_M2M m WHERE m.TimeNew > DATEADD(day,-45,GETDATE()) ORDER BY m.ID DESC`) + if err != nil { + log.Printf("depo-events: чтение FTV_M2M: %v", err) + return + } + defer rows.Close() + for rows.Next() { + var guid, state, doc, comment, lineErr, docState string + var procAt sql.NullTime + if rows.Scan(&guid, &state, &doc, &comment, &lineErr, &docState, &procAt) != nil { + continue + } + l := globalDepoLinks.Get(guid) + // состояние документа в DEPO (ручные действия депозитария: отмена, архив) + if docState != "" && (l == nil || l.DocState != docState) { + globalDepoLinks.update(guid, func(x *DepoLink) { + x.DocState = docState + if procAt.Valid { + x.DocStateAt = procAt.Time + } + }) + if l != nil && l.DocState != "" && docState != l.DocState { + log.Printf("depo-events: документ DEPO %s по %s: состояние «%s»", doc, shortGUID(guid), docState) + if strings.Contains(strings.ToLower(docState), "отмен") { + raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.") + } + } + } + if doc != "" && doc != "0" && (l == nil || l.DocNo != doc) { + globalDepoLinks.update(guid, func(x *DepoLink) { x.DocNo, x.DocAt, x.LastEvent = doc, time.Now(), "FTV_M2M.DOC" }) + log.Printf("depo-events: DEPO создала поручение по %s: %s", shortGUID(guid), doc) + } + if state == "ERROR" || lineErr != "" { + msg := strings.TrimSpace(comment + " " + lineErr) + if l == nil || len(l.Errors) == 0 || !strings.HasSuffix(l.Errors[len(l.Errors)-1], msg) { + globalDepoLinks.update(guid, func(x *DepoLink) { + x.Errors = append(x.Errors, time.Now().Format("02.01 15:04")+" "+msg) + x.LastEvent = "FTV_M2M.STATE=ERROR" + }) + raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg) + } + } + } +} + +// ---- справочник участников → FTV_M2M_HANDBOOK (полная перезапись) ---- + +var depoHandbookWritten time.Time + +func depoWriteHandbook(rc *RuntimeConfig) error { + raw, err := os.ReadFile(handbookFile) + if err != nil { + return err + } + var hb struct { + At string `json:"at"` + Participants []HandbookEntry `json:"participants"` + Places []HandbookEntry `json:"settlement_places"` + } + if err := json.Unmarshal(raw, &hb); err != nil { + return err + } + db, err := depoEventsDB(rc) + if err != nil { + return err + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if !depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") { + if _, err := db.ExecContext(ctx, depoTableSpecByName("FTV_M2M_HANDBOOK").createSQL(false)); err != nil { + return fmt.Errorf("создание FTV_M2M_HANDBOOK: %w — создайте таблицы: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", err) + } + log.Printf("depo-events: таблица FTV_M2M_HANDBOOK создана в %s", rc.Snapshot().Fancy.DepoDB()) + } + hbAt, _ := time.Parse(time.RFC3339, hb.At) + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + // полная перезапись (согласовано с программистом); DELETE — у логина bj нет права на TRUNCATE + if _, err := tx.ExecContext(ctx, "DELETE FROM dbo.FTV_M2M_HANDBOOK"); err != nil { + tx.Rollback() + return err + } + ins := func(e HandbookEntry, kind string) error { + short := e.Name + if short == "" { + short = e.DisplayName + } + _, err := tx.ExecContext(ctx, `INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_FULL, NAME_SHORT, NAME_DISPLAY, NAME_ENG, HANDBOOK_AT, UPDATED_AT, ACTIVE) + VALUES (@inn, @code, @kind, @full, @short, @disp, @eng, @at, GETDATE(), 1)`, + sql.Named("inn", e.INN), sql.Named("code", nullStr(e.Code)), sql.Named("kind", kind), sql.Named("full", nullStr(e.FullName)), sql.Named("short", nullStr(short)), + sql.Named("disp", nullStr(e.DisplayName)), sql.Named("eng", nullStr(e.EngName)), sql.Named("at", hbAt)) + return err + } + n := 0 + for _, p := range hb.Participants { + kind := "DEPO" + if p.Kind == "broker" { + kind = "BROKER" + } + if err := ins(p, kind); err != nil { + tx.Rollback() + return err + } + n++ + } + for _, p := range hb.Places { + if err := ins(p, "PLACE"); err != nil { + tx.Rollback() + return err + } + n++ + } + if err := tx.Commit(); err != nil { + return err + } + depoHandbookWritten = time.Now() + log.Printf("depo-events: справочник участников записан в FTV_M2M_HANDBOOK: %d строк", n) + return nil +} + +// depoEventsLoop — чтение событий DEPO раз в минуту; справочник — при старте и раз в 6 часов +// (плюс сразу после получения нового от НРД — вызов из saveHandbook). +func (s *Server) depoEventsLoop(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + first := true + for { + if !first { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + first = false + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("depo-events: panic: %v", r) + } + }() + if time.Since(depoHandbookWritten) > 6*time.Hour { + if err := depoWriteHandbook(s.rc); err != nil { + log.Printf("depo-events: справочник: %v", err) + depoHandbookWritten = time.Now().Add(-5 * time.Hour) // повтор через час + } + } + depoPollEvents(ctx, s.rc) + }() + healthBeat("depo-events") + } +} + +// ---- обработка трансферных таблиц силами bj (замена джобы SQL Agent bj2depo) ---- + +func depoJobProcName(f FancyMSSQLSettings) string { + if p := strings.TrimSpace(f.DepoJobProc); p != "" { + return p + } + return "dbo.FTV_M2M_JOB" +} + +func depoJobEvery(f FancyMSSQLSettings) int { + if f.DepoJobEverySec >= 5 { + return f.DepoJobEverySec + } + return 10 +} + +var depoJobErrAlerted time.Time + +// depoJobOnce — один вызов процедуры обработки. Итог сохраняется в настройках (для карточки). +func depoJobOnce(rc *RuntimeConfig) { + f := rc.Snapshot().Fancy + db, err := depoEventsDB(rc) + if err != nil { + return + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + proc := depoJobProcName(f) + if !regexp.MustCompile(`^[A-Za-z0-9_.\[\]]+$`).MatchString(proc) { + return + } + start := time.Now() + _, err = db.ExecContext(ctx, "exec "+proc) + res := fmt.Sprintf("%s: OK за %s", start.Format("02.01 15:04:05"), time.Since(start).Round(time.Millisecond)) + if err != nil { + res = fmt.Sprintf("%s: ОШИБКА %s", start.Format("02.01 15:04:05"), quikErrorDetail(err)) + if time.Since(depoJobErrAlerted) > time.Hour { + depoJobErrAlerted = time.Now() + hint := "" + if strings.Contains(strings.ToLower(err.Error()), "не удалось найти") || strings.Contains(strings.ToLower(err.Error()), "could not find") || strings.Contains(strings.ToLower(err.Error()), "разрешение") || strings.Contains(strings.ToLower(err.Error()), "permission") { + hint = " Скорее всего, у логина bj нет права на процедуру: GRANT EXECUTE ON " + proc + " TO " + f.User + ";" + } + raiseSystemAlert(rc, "DEPO: bj не может запустить обработку трансферных таблиц", err.Error()+hint) + log.Printf("depo-job: %v", err) + } + } + cur := rc.Snapshot().Fancy + if cur.DepoJobLast != res { + cur.DepoJobLast, cur.DepoJobLastAt = res, time.Now() + _ = rc.UpdateFancy(cur) + } +} + +// depoJobLoop — вызывает процедуру обработки, пока включён режим Fancy.DepoJobRun. +func (s *Server) depoJobLoop(ctx context.Context) { + for { + f := s.rc.Snapshot().Fancy + wait := time.Duration(depoJobEvery(f)) * time.Second + if f.DepoJobRun && strings.TrimSpace(f.Server) != "" { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("depo-job: panic: %v", r) + } + }() + depoJobOnce(s.rc) + }() + } else { + wait = 15 * time.Second + } + healthBeat("depo-job") + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } +} + +// ---- выбор способа обработки при настройке: проверки и создание джобы ---- + +const depoJobName = "bj2depo" + +// depoJobAgentScript — скрипт для администратора SQL (ручной вариант). +func depoJobAgentScript(f FancyMSSQLSettings) string { + return fmt.Sprintf(`USE msdb; +EXEC dbo.sp_add_job @job_name = N'%[1]s', @enabled = 1, @description = N'bj2depo: обработка трансферных таблиц M2M (bj)'; +EXEC dbo.sp_add_jobstep @job_name = N'%[1]s', @step_name = N'%[1]s', @subsystem = N'TSQL', @database_name = N'%[2]s', @command = N'exec %[3]s;'; +EXEC dbo.sp_add_jobschedule @job_name = N'%[1]s', @name = N'%[1]s', @enabled = 1, @freq_type = 4, @freq_interval = 1, @freq_subday_type = 2, @freq_subday_interval = %[4]d, @active_start_time = 0; +EXEC dbo.sp_add_jobserver @job_name = N'%[1]s';`, depoJobName, f.DepoDB(), depoJobProcName(f), depoJobEvery(f)) +} + +// depoJobGrantScript — права для варианта «внутри bj». +func depoJobGrantScript(f FancyMSSQLSettings) string { + return fmt.Sprintf("USE %s;\nGRANT EXECUTE ON %s TO %s;", f.DepoDB(), depoJobProcName(f), f.User) +} + +// depoJobAgentRightsScript — права для варианта «bj создаёт джобу сам». +func depoJobAgentRightsScript(f FancyMSSQLSettings) string { + return fmt.Sprintf(`USE msdb; +CREATE USER %[1]s FOR LOGIN %[1]s; +ALTER ROLE SQLAgentOperatorRole ADD MEMBER %[1]s; +-- служебная учётная запись SQL Agent должна иметь право выполнять %[2]s в %[3]s`, f.User, depoJobProcName(f), f.DepoDB()) +} + +func depoJobPing(ctx context.Context, db *sql.DB, wait time.Duration) (bool, string) { + guid := strings.ToUpper(fmt.Sprintf("%08x-0000-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF)) + if _, err := db.ExecContext(ctx, "INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT) VALUES (@g, NULL, 'TODO', 'BJ_PING', GETDATE(), N'проверка обработчика из bj')", sql.Named("g", guid)); err != nil { + return false, "не удалось записать проверочное событие: " + quikErrorDetail(err) + } + defer db.ExecContext(context.Background(), "DELETE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid)) + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + var st string + if err := db.QueryRowContext(ctx, "SELECT ISNULL(STATE,'') FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g", sql.Named("g", guid)).Scan(&st); err == nil && st != "TODO" && st != "" { + return true, fmt.Sprintf("проверочное событие BJ_PING обработано за %s (STATE=%s)", time.Since(deadline.Add(-wait)).Round(time.Second), st) + } + time.Sleep(3 * time.Second) + } + return false, fmt.Sprintf("проверочное событие BJ_PING не обработано за %s — обработчик не запущен (или процедура не знает BJ_PING)", wait) +} + +// depoJobCheck — проверка готовности выбранного способа обработки. +func depoJobCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) { + f := rc.Snapshot().Fancy + db, err := depoEventsDB(rc) + if err != nil { + return "подключение к DEPO: " + err.Error(), false + } + defer db.Close() + if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") { + return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M* — создайте их: Настройка → Обмен Fancy → «Трансферные таблицы DEPO»", false + } + if ok, msg := depoRunAsCheck(ctx, db, f); !ok { + return msg, false + } + if fails, err := depoJobFailures(ctx, db, 1, 5); err == nil && len(fails) > 0 { + return fmt.Sprintf("обработчик за последние сутки не смог обработать записей: %d (последняя: %s %s — %s). Устраните причину и верните их в очередь: Обмен с DEPO → «Вернуть в очередь»", + len(fails), fails[0].TableRu(), fails[0].Type, fails[0].Comment), false + } + switch f.DepoJobMode { + case "bj": + var n int + _ = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM fn_my_permissions(@p,'OBJECT') WHERE permission_name='EXECUTE'", sql.Named("p", depoJobProcName(f))).Scan(&n) + if n == 0 { + return "у логина " + f.User + " нет права EXECUTE на " + depoJobProcName(f) + " — выполните на сервере: " + strings.ReplaceAll(depoJobGrantScript(f), "\n", " "), false + } + start := time.Now() + if _, err := db.ExecContext(ctx, "exec "+depoJobProcName(f)); err != nil { + return "вызов " + depoJobProcName(f) + ": " + quikErrorDetail(err), false + } + return fmt.Sprintf("право есть, процедура %s выполнена за %s; bj будет вызывать её каждые %d с", depoJobProcName(f), time.Since(start).Round(time.Millisecond), depoJobEvery(f)), true + case "sql-auto", "sql-manual": + // 1) джоба видна через msdb? + var seen string + if mdb, err := fancyOpen(f, "msdb"); err == nil { + var enabled int + if e := mdb.QueryRowContext(ctx, "SELECT enabled FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&enabled); e == nil { + if enabled == 1 { + seen = "джоба " + depoJobName + " есть и включена; " + } else { + seen = "джоба " + depoJobName + " есть, но ВЫКЛЮЧЕНА; " + } + } + mdb.Close() + } + // 2) реально ли обрабатывает: BJ_PING + ok, msg := depoJobPing(ctx, db, 40*time.Second) + return seen + msg, ok + } + return "способ обработки не выбран", false +} + +// depoJobCreateAgent — создать джобу SQL Agent от имени логина bj (нужна роль SQLAgentOperatorRole). +func depoJobCreateAgent(ctx context.Context, rc *RuntimeConfig) error { + f := rc.Snapshot().Fancy + mdb, err := fancyOpen(f, "msdb") + if err != nil { + return fmt.Errorf("подключение к msdb: %w", err) + } + defer mdb.Close() + var n int + if err := mdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM msdb.dbo.sysjobs WHERE name=@n", sql.Named("n", depoJobName)).Scan(&n); err != nil { + return fmt.Errorf("нет доступа к msdb (%s). Нужны права: %s", quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " ")) + } + if n > 0 { + return fmt.Errorf("джоба %s уже существует", depoJobName) + } + for _, stmt := range strings.Split(depoJobAgentScript(f), "\n") { + stmt = strings.TrimSpace(stmt) + if stmt == "" || strings.HasPrefix(stmt, "USE ") { + continue + } + if _, err := mdb.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("%s → %s. Нужны права: %s", stmt[:min(40, len(stmt))], quikErrorDetail(err), strings.ReplaceAll(depoJobAgentRightsScript(f), "\n", " ")) + } + } + log.Printf("depo-job: джоба SQL Agent %s создана", depoJobName) + return nil +} + +// DepoJobView — данные карточки «Где запускается обработка». +type DepoJobView struct { + Name, Proc, GrantScript, AgentRights, AgentScript string + Every int +} + +func depoJobView(f FancyMSSQLSettings) DepoJobView { + return DepoJobView{Name: depoJobName, Proc: depoJobProcName(f), Every: depoJobEvery(f), GrantScript: depoJobGrantScript(f), AgentRights: depoJobAgentRightsScript(f), AgentScript: depoJobAgentScript(f)} +} + +// ---- выгрузка истории тестов (TEST3, банк Зенит / робот) в трансферные таблицы ---- + +func orderRespAt(resps []orderResponse) time.Time { + for _, r := range resps { + if r.Done { + if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil { + return t + } + } + } + return time.Time{} +} + +// depoReplayHistory — переписывает в FTV_M2M* все сохранённые в bj переводы с их фактической +// историей (получен/отправлен, решения с кодами, поручения 16/16-1, MS010). Идемпотентно: +// шапки и события по ключам не дублируются. Возвращает отчёт для оператора. +func depoReplayHistory(ctx context.Context, rc *RuntimeConfig, svc *Service, onlyCP string) string { + var sb strings.Builder + cpOK := func(code string) bool { + if strings.TrimSpace(onlyCP) == "" { + return true + } + for _, c := range strings.Split(onlyCP, ",") { + if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(code)) { + return true + } + } + return false + } + place := rc.Snapshot().NSD.SectionID + nIn, nOut, nEv := 0, 0, 0 + ev := func(at time.Time, guid, ref, typ, codes, comment string) { + depoEventAt(rc, at, guid, ref, typ, codes, comment) + nEv++ + } + // входящие (мы зачисляли) + if globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + if !uuidRe.MatchString(strings.ToUpper(it.GUID)) || !cpOK(it.SenderCode) { + continue + } + var lines []depoLine + for _, s := range it.Securities { + lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place}) + } + name := strings.TrimSpace(strings.Join([]string{it.InvestorLast, it.InvestorFirst, it.InvestorMiddle}, " ")) + cp := handbookNameByCode(it.SenderCode) + depoUpsertOrder(rc, it.GUID, "IN", it.SenderCode, name, strings.TrimSpace(it.DocSeries+" "+it.DocNumber), it.DocSeries, it.DocNumber, lines, "TEST3: входящий от "+it.SenderCode+" "+cp) + nIn++ + ev(it.ReceivedAt, it.GUID, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp) + if it.Status == "answered" { + for _, s := range it.Securities { + switch s.Decision { + case "confirmed": + ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту") + case "rejected": + ev(it.AnsweredAt, it.GUID, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode)) + } + } + } + if it.OrderSubmitted { + ev(it.OrderAt, it.GUID, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление), CONTR_D_ID "+it.OrderContrDID) + resps := findOrderResponses(it.OrderContrDID) + if done, rejected, text := orderRespVerdict(resps); done { + at := orderRespAt(resps) + if rejected { + ev(at, it.GUID, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text) + } else { + ev(at, it.GUID, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)") + ev(at, it.GUID, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + } + } + } + } + } + // исходящие (мы списывали) + if svc != nil { + deals, err := svc.Repo().List(ctx, m2mcore.Filter{Limit: 500}) + if err != nil { + fmt.Fprintf(&sb, "исходящие: ошибка чтения заявок: %v\n", err) + } + for _, d := range deals { + if d == nil || d.Request == nil || !uuidRe.MatchString(strings.ToUpper(string(d.GUID))) { + continue + } + guid := string(d.GUID) + req := d.Request + inv := req.Data.InvestorInformation + ser := "" + if inv.IdentityDocument.DocumentSeries != nil { + ser = string(*inv.IdentityDocument.DocumentSeries) + } + num := string(inv.IdentityDocument.DocumentNumber) + var lines []depoLine + refISIN := map[string]string{} + for _, s := range req.Data.TransferredSecurities.Securities { + isin := quikSecISIN(s) + refISIN[string(s.ReferenceID)] = isin + lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: isin, Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place}) + } + inn := string(req.Data.ReceivingDepository.INN) + cp := handbookCodeByINN(inn) + if !cpOK(cp) { + continue + } + name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " ")) + depoUpsertOrder(rc, guid, "OUT", cp, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "TEST3: исходящий, получатель "+cp+" "+handbookNameByCode(cp)) + nOut++ + ev(d.CreatedAt, guid, "", "BJ_SENT", "", "запрос на перевод отправлен в НРД") + if d.Decision != nil { + for _, sec := range d.Decision.Data.Securities { + ref := string(sec.ReferenceID) + if sec.TransferDecision.Rejection != nil { + ev(d.UpdatedAt, guid, ref, "BJ_CP_REJECT", strings.Join(sec.TransferDecision.Rejection.Codes, ";"), "отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes)) + } else if sec.TransferDecision.Confirmation != nil { + ev(d.UpdatedAt, guid, ref, "BJ_CP_CONFIRM", "", "принимающий депозитарий подтвердил") + } + } + } + if d.Response != nil && strings.EqualFold(string(d.Response.StatusCode), "ERROR") { + var codes []string + for _, rr := range d.Response.Responses { + codes = append(codes, string(rr.Code)) + } + ev(d.UpdatedAt, guid, "", "BJ_SERVICE_ERROR", strings.Join(codes, ";"), "отклонено сервисом MOEX МОСТ: "+rejectCodesLabel(codes)) + } + if globalOutgoingStore != nil { + if od, ok := scanForm16Deal(globalOutgoingStore, guid); ok && od.Submitted { + ev(od.OrderAt, guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16 (списание), CONTR_D_ID "+strings.Join(od.ContrDIDs, ", ")) + resps, _ := outgoingResponses(od.ContrDIDs) + if done, rejected, text := orderRespVerdict(resps); done { + at := orderRespAt(resps) + if rejected { + ev(at, guid, "", "BJ_ORDER_REJECTED", "", "НРД отверг депо-поручение: "+text) + } else { + ev(at, guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010)") + ev(at, guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010)") + } + } + } + } + } + } + fmt.Fprintf(&sb, "выгружено: входящих %d, исходящих %d, событий записано (с учётом уже имевшихся) %d", nIn, nOut, nEv) + return sb.String() +} + +// ---- тестовый прогон входящего в DEPO с реальным клиентом ---- + +// depoSimulateIncoming — копия входящего перевода (бумаги и решения как в оригинале) на реального +// клиента DEPO по паспорту: новый GUID, шапка IN + строки + события полного сценария приёма. +// В НРД ничего не отправляется — только трансферные таблицы DEPO. +func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTransfer, ser, num string) (string, error) { + if it == nil { + return "", fmt.Errorf("входящий не найден") + } + db, err := depoEventsDB(rc) + if err != nil { + return "", err + } + defer db.Close() + serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "") + numN := strings.TrimSpace(num) + var name string + if err := db.QueryRowContext(ctx, `SELECT TOP 1 f.NAME FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID + WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0`, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil { + return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num) + } + guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF)) + place := rc.Snapshot().NSD.SectionID + var lines []depoLine + for _, s := range it.Securities { + lines = append(lines, depoLine{Ref: s.ReferenceID, ISIN: s.ISIN, Code: s.SecurityCode, Amount: parseQty(s.Quantity), Place: place}) + } + cp := handbookNameByCode(it.SenderCode) + if !depoUpsertOrder(rc, guid, "IN", it.SenderCode, name, strings.TrimSpace(ser+" "+num), ser, num, lines, "ТЕСТ по образцу "+shortGUID(it.GUID)+": входящий от "+it.SenderCode+" "+cp+", реальный клиент") { + return "", fmt.Errorf("не удалось записать шапку в FTV_M2M") + } + now := time.Now() + depoEventAt(rc, now, guid, "", "BJ_RECEIVED", "", "входящий перевод получен от "+it.SenderCode+" "+cp+" (тест по образцу "+shortGUID(it.GUID)+")") + anyConfirmed := false + for _, s := range it.Securities { + switch s.Decision { + case "confirmed": + anyConfirmed = true + depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту") + case "rejected": + depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_REJECT", s.RejectCode, "отказ контрагенту: "+m2mCodeLabel(s.RejectCode)) + default: + anyConfirmed = true + depoEventAt(rc, now.Add(time.Minute), guid, s.ReferenceID, "BJ_DECISION_CONFIRM", "", "приём подтверждён контрагенту (тест)") + } + } + if anyConfirmed { + depoEventAt(rc, now.Add(2*time.Minute), guid, "", "BJ_ORDER_SENT", "", "подано депо-поручение 16/1 (зачисление) в НРД (тест)") + depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_ORDER_ACCEPTED", "", "НРД принял депо-поручение (отчёт MS010) (тест)") + depoEventAt(rc, now.Add(3*time.Minute), guid, "", "BJ_EXECUTED", "", "перевод исполнен (MS010) (тест)") + } + return guid, nil +} diff --git a/internal/lkgateway/depohealth.go b/internal/lkgateway/depohealth.go new file mode 100644 index 0000000..02d6415 --- /dev/null +++ b/internal/lkgateway/depohealth.go @@ -0,0 +1,146 @@ +package lkgateway + +// Здоровье обработки трансферных таблиц DEPO: пользователь, от имени которого работает +// процедура обработки (EXECUTE AS), записи, помеченные обработчиком ERROR, и возврат их +// в очередь после устранения причины. У каждого клиента свой набор пользователей в базе +// DEPO, поэтому имя настраивается (Fancy.DepoJobRunAs) и проверяется при настройке. + +import ( + "context" + "database/sql" + "fmt" + "log" + "regexp" + "strings" + "time" +) + +var depoRunAsRe = regexp.MustCompile(`^[A-Za-z0-9_.\-\\]{1,128}$`) + +// depoJobRunAs — пользователь базы DEPO, от имени которого работает процедура обработки. +func depoJobRunAs(f FancyMSSQLSettings) string { + if u := strings.TrimSpace(f.DepoJobRunAs); u != "" { + return u + } + return "SYSDBA" +} + +// depoRunAsCheck — есть ли в базе DEPO пользователь для EXECUTE AS. USER_ID видит пользователя, +// даже если логину bj закрыт просмотр списка пользователей базы. +func depoRunAsCheck(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) (bool, string) { + name := depoJobRunAs(f) + var id sql.NullInt64 + if err := db.QueryRowContext(ctx, "SELECT USER_ID(@n)", sql.Named("n", name)).Scan(&id); err != nil { + return false, "не удалось проверить пользователя " + name + ": " + quikErrorDetail(err) + } + if !id.Valid { + return false, fmt.Sprintf("в базе %s нет пользователя %s, от имени которого работает процедура обработки: создайте его или укажите другого пользователя в настройках (Обмен Fancy → «Где запускается обработка»)", f.DepoDB(), name) + } + return true, fmt.Sprintf("пользователь %s для процедуры обработки в базе есть", name) +} + +// depoJobFailure — запись трансферных таблиц, которую обработчик пометил ERROR. +type depoJobFailure struct { + Table, GUID, Ref, Type, Comment string + ID int64 + At time.Time +} + +func (f depoJobFailure) TableRu() string { + if f.Table == "FTV_M2M" { + return "поручение" + } + return "событие" +} + +// depoJobFailures — записи с ошибкой обработки за days дней, новые первыми. +func depoJobFailures(ctx context.Context, db *sql.DB, days, limit int) ([]depoJobFailure, error) { + rows, err := db.QueryContext(ctx, `SELECT TOP (@n) * FROM ( + SELECT 'FTV_M2M' AS T, ID, CAST(GUID AS varchar(36)) AS G, '' AS R, ISNULL(DIRECTION,'') AS K, ISNULL(TimeEnd, TimeNew) AS A, ISNULL(COMMENT,'') AS C + FROM dbo.FTV_M2M WHERE STATE='ERROR' AND ISNULL(TimeEnd, TimeNew) > DATEADD(day, -@d, GETDATE()) + UNION ALL + SELECT 'FTV_M2M_EVENTS', ID, CAST(GUID AS varchar(36)), ISNULL(REFERENCE,''), EVENT_TYPE, ISNULL(TimeEnd, TimeNew), ISNULL(COMMENT,'') + FROM dbo.FTV_M2M_EVENTS WHERE STATE='ERROR' AND EVENT_TYPE LIKE 'BJ[_]%' AND ISNULL(TimeEnd, TimeNew) > DATEADD(day, -@d, GETDATE()) + ) x ORDER BY A DESC`, sql.Named("n", limit), sql.Named("d", days)) + if err != nil { + return nil, err + } + defer rows.Close() + var out []depoJobFailure + for rows.Next() { + var f depoJobFailure + var at sql.NullTime + if err := rows.Scan(&f.Table, &f.ID, &f.GUID, &f.Ref, &f.Type, &at, &f.Comment); err != nil { + return out, err + } + if at.Valid { + f.At = at.Time + } + f.GUID = strings.ToUpper(f.GUID) + out = append(out, f) + } + return out, rows.Err() +} + +// depoRequeueFailures — вернуть записи с ошибкой (за days дней) в очередь обработчика. Текст +// ошибки в COMMENT заменяется: у событий отказа шлюза — исходной причиной из пути заявки. +func depoRequeueFailures(ctx context.Context, db *sql.DB, days int) (orders, events int64, err error) { + fails, err := depoJobFailures(ctx, db, days, 500) + if err != nil { + return 0, 0, err + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, err + } + for _, f := range fails { + comment := "повторная обработка из bj" + q := "UPDATE dbo.FTV_M2M SET STATE='TODO', TimeBeg=NULL, TimeEnd=NULL, COMMENT=@c WHERE ID=@id AND STATE='ERROR'" + if f.Table == "FTV_M2M_EVENTS" { + q = "UPDATE dbo.FTV_M2M_EVENTS SET STATE='TODO', TimeBeg=NULL, TimeEnd=NULL, COMMENT=@c WHERE ID=@id AND STATE='ERROR'" + if lf := globalLKFlows.Get(f.GUID); f.Type == "BJ_GATEWAY_REJECT" && lf != nil && lf.Reason != "" { + comment = lf.Reason + } + } + res, e := tx.ExecContext(ctx, q, sql.Named("c", comment), sql.Named("id", f.ID)) + if e != nil { + tx.Rollback() + return 0, 0, e + } + n, _ := res.RowsAffected() + if f.Table == "FTV_M2M" { + orders += n + // строки поручения, помеченные ошибкой, тоже возвращаем + if _, e := tx.ExecContext(ctx, "UPDATE dbo.FTV_M2M_LINES SET STATE='TODO', TimeBeg=NULL, TimeEnd=NULL WHERE GUID=@g AND STATE='ERROR'", sql.Named("g", f.GUID)); e != nil { + tx.Rollback() + return 0, 0, e + } + } else { + events += n + } + } + if err := tx.Commit(); err != nil { + return 0, 0, err + } + return orders, events, nil +} + +var depoFailAlertedID int64 + +// depoAlertEventFailures — оповещение о новых ошибках обработки событий BJ_* (поручения с +// ошибкой оповещает depoPollOrders). +func depoAlertEventFailures(ctx context.Context, db *sql.DB, rc *RuntimeConfig) { + var id sql.NullInt64 + var typ, guid, comment string + var n int + err := db.QueryRowContext(ctx, `SELECT COUNT(*) OVER (), ID, EVENT_TYPE, CAST(GUID AS varchar(36)), ISNULL(COMMENT,'') + FROM dbo.FTV_M2M_EVENTS WHERE STATE='ERROR' AND EVENT_TYPE LIKE 'BJ[_]%' AND ID > @last AND ISNULL(TimeEnd, TimeNew) > DATEADD(day, -1, GETDATE()) + ORDER BY ID DESC OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY`, sql.Named("last", depoFailAlertedID)).Scan(&n, &id, &typ, &guid, &comment) + if err != nil || !id.Valid { + return + } + depoFailAlertedID = id.Int64 + log.Printf("depo-events: обработчик пометил ERROR событий: %d, последнее %s по %s: %s", n, typ, shortGUID(guid), comment) + raiseSystemAlert(rc, fmt.Sprintf("DEPO: обработчик не смог обработать события (%d)", n), + fmt.Sprintf("Последнее: %s по переводу %s — %s. Устраните причину и верните записи в очередь: Обмен с DEPO → «Вернуть в очередь».", typ, shortGUID(guid), comment)) +} diff --git a/internal/lkgateway/depojob_live_test.go b/internal/lkgateway/depojob_live_test.go new file mode 100644 index 0000000..d778323 --- /dev/null +++ b/internal/lkgateway/depojob_live_test.go @@ -0,0 +1,23 @@ +//go:build live + +package lkgateway + +import ( + "context" + "testing" + "time" +) + +func TestDepoJobCheckLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + f := rc.Snapshot().Fancy + t.Logf("режим: %q, процедура %s, интервал %d", f.DepoJobMode, depoJobProcName(f), depoJobEvery(f)) + msg, ok := depoJobCheck(ctx, rc) + t.Logf("проверка готовности: ok=%v — %s", ok, msg) + t.Logf("скрипт для администратора:\n%s", depoJobAgentScript(f)) +} diff --git a/internal/lkgateway/depoqual_live_test.go b/internal/lkgateway/depoqual_live_test.go new file mode 100644 index 0000000..c1f731b --- /dev/null +++ b/internal/lkgateway/depoqual_live_test.go @@ -0,0 +1,42 @@ +//go:build live + +package lkgateway + +import ( + "context" + "testing" + "time" +) + +// go test -tags live -run TestDepoQualifiedLive ./internal/lkgateway/ (от bj: читает setup.json) +func TestDepoQualifiedLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Skip(err) + } + db, err := fancyOpen(rc.Snapshot().Fancy, rc.Snapshot().Fancy.DepoDB()) + if err != nil { + t.Skip(err) + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + rows, err := db.QueryContext(ctx, `SELECT v, face FROM ( + SELECT CAST(o.VAL AS varchar(20)) AS v, MIN(c.FACE) AS face FROM OD_CLIENTS c JOIN OD_OPTIONS o ON o.OBJECT = c.FACE AND o.DESCR = 65325 + AND o.B_DATE <= GETDATE() AND (o.E_DATE IS NULL OR o.E_DATE > GETDATE()) WHERE c.NUMBER LIKE N'ВТНКЦ%' GROUP BY CAST(o.VAL AS varchar(20)) + UNION ALL + SELECT 'нет', MIN(c.FACE) FROM OD_CLIENTS c WHERE c.NUMBER LIKE N'ВТНКЦ%' AND NOT EXISTS (SELECT 1 FROM OD_OPTIONS o WHERE o.OBJECT = c.FACE AND o.DESCR = 65325)) x`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var v string + var face int64 + if rows.Scan(&v, &face) != nil { + continue + } + warn, det := depoFaceQualified(ctx, db, face) + t.Logf("VAL=%s лицо %d: предупреждение=%v — %s", v, face, warn, det) + } +} diff --git a/internal/lkgateway/depoqueue.go b/internal/lkgateway/depoqueue.go new file mode 100644 index 0000000..d544edb --- /dev/null +++ b/internal/lkgateway/depoqueue.go @@ -0,0 +1,315 @@ +package lkgateway + +// Страница «Обмен с DEPO (Fancy)» — /admin/fancy-queue. Показывает реальный +// обмен через трансферные таблицы FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS: +// какие переводы bj положил в DEPO, что ответил job bj2depo (DEPO_ORDER_CREATED / +// DEPO_ERROR), в каком состоянии документ в депозитарии и какие события ещё +// не обработаны. Заменила прежнюю «очередь на проверку крана». + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +type depoQueueLine struct { + ISIN, SecCode string + Amount string + State string + Comment string +} + +type depoQueueEvent struct { + Ref, State, Type, Codes, Comment string + At time.Time +} + +type depoQueueRow struct { + GUID string + State string + Direction string + Deponent string + DeponentDoc string + CP string + CreatedAt time.Time + Doc string + DocState string + DocAt time.Time + Comment string + Lines []depoQueueLine + Events []depoQueueEvent + Pending int // события со STATE=TODO (job ещё не обработал) + Errors []string + LastEvent string + LastAt time.Time + Link *DepoLink +} + +func (r depoQueueRow) DirectionRu() string { + if strings.EqualFold(r.Direction, "OUT") { + return "↗ списание (мы отдаём)" + } + return "↘ зачисление (мы принимаем)" +} + +// StageRu — человеческое состояние перевода по совокупности признаков. +func (r depoQueueRow) StageRu() (cls, text string) { + up := strings.ToUpper(r.State) + switch { + case len(r.Errors) > 0 || up == "ERROR": + return "rejected", "ошибка DEPO" + case strings.Contains(strings.ToLower(r.DocState), "отмен"): + return "rejected", "документ отменён в DEPO" + case strings.Contains(strings.ToLower(r.DocState), "конец"): + return "confirmed", "документ проведён" + case r.Doc != "": + return "sent", "документ создан: " + r.DocState + case r.Pending > 0: + return "awaiting_decision", "ждёт job bj2depo" + case up == "SUCCESS" || up == "DONE": + return "confirmed", "обработан" + } + if up == "" { + return "", "—" + } + return "", up +} + +type depoQueueSummary struct { + Total, Pending, WithDoc, Errors int + Handbook int + HandbookAt time.Time + HasHandbook bool + LastEventAt time.Time +} + +func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQueueRow, depoQueueSummary, error) { + var sum depoQueueSummary + db, err := depoEventsDB(rc) + if err != nil { + return nil, sum, err + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if !depoTableExists(ctx, db, "FTV_M2M") { + return nil, sum, errNoDepoTables + } + docCol := "NULL" + if depoHasColumn(ctx, db, "FTV_M2M", "DOC") { + docCol = "CAST(m.DOC AS varchar(32))" + } + timeCol := "NULL" + for _, c := range []string{"TimeNew", "TIME_NEW", "CREATED_AT"} { + if depoHasColumn(ctx, db, "FTV_M2M", c) { + timeCol = "m." + c + break + } + } + rows, err := db.QueryContext(ctx, `SELECT TOP (@n) CAST(m.GUID AS varchar(36)), ISNULL(m.STATE,''), ISNULL(m.DIRECTION,''), ISNULL(m.DEPONENT_NAME,''), ISNULL(m.DEPONENT_DOC,''), ISNULL(m.CONTRAGENT_M2M_CODE,''), `+timeCol+`, ISNULL(`+docCol+`,''), ISNULL(m.COMMENT,''), + ISNULL((SELECT TOP 1 ISNULL(st.NAME,'') FROM OD_DOCS d LEFT JOIN OD_DOC_STATES st ON st.ID=d.STATE AND st.D_CAT=0 WHERE CAST(d.ID AS varchar(32))=`+docCol+`),''), + (SELECT TOP 1 d.PROC_DATE FROM OD_DOCS d WHERE CAST(d.ID AS varchar(32))=`+docCol+`) + FROM dbo.FTV_M2M m ORDER BY `+strings.Replace(timeCol, "NULL", "m.GUID", 1)+` DESC`, sql.Named("n", limit)) + if err != nil { + return nil, sum, err + } + var out []depoQueueRow + idx := map[string]int{} + for rows.Next() { + var r depoQueueRow + var created, docAt sql.NullTime + if err := rows.Scan(&r.GUID, &r.State, &r.Direction, &r.Deponent, &r.DeponentDoc, &r.CP, &created, &r.Doc, &r.Comment, &r.DocState, &docAt); err != nil { + rows.Close() + return nil, sum, err + } + if created.Valid { + r.CreatedAt = created.Time + } + if docAt.Valid { + r.DocAt = docAt.Time + } + r.GUID = strings.ToUpper(r.GUID) + r.Link = globalDepoLinks.Get(r.GUID) + idx[r.GUID] = len(out) + out = append(out, r) + } + rows.Close() + if len(out) == 0 { + return out, sum, nil + } + // строки + if depoTableExists(ctx, db, "FTV_M2M_LINES") { + lr, err := db.QueryContext(ctx, `SELECT CAST(l.GUID AS varchar(36)), ISNULL(l.ISIN,''), ISNULL(l.SEC_CODE,''), ISNULL(CAST(l.AMOUNT AS varchar(40)),''), ISNULL(l.STATE,''), ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID IN (SELECT TOP (@n) GUID FROM dbo.FTV_M2M ORDER BY `+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC)`, sql.Named("n", limit)) + if err == nil { + for lr.Next() { + var g string + var l depoQueueLine + if lr.Scan(&g, &l.ISIN, &l.SecCode, &l.Amount, &l.State, &l.Comment) == nil { + if i, ok := idx[strings.ToUpper(g)]; ok { + l.Amount = strings.TrimRight(strings.TrimRight(l.Amount, "0"), ".") + out[i].Lines = append(out[i].Lines, l) + if strings.EqualFold(l.State, "ERROR") && l.Comment != "" { + out[i].Errors = append(out[i].Errors, l.Comment) + } + } + } + } + lr.Close() + } + } + // события + if depoTableExists(ctx, db, "FTV_M2M_EVENTS") { + er, err := db.QueryContext(ctx, `SELECT CAST(e.GUID AS varchar(36)), ISNULL(e.REFERENCE,''), ISNULL(e.STATE,''), ISNULL(e.EVENT_TYPE,''), e.EVENT_DATE, ISNULL(e.M2M_REJECT_CODE,''), ISNULL(e.COMMENT,'') FROM dbo.FTV_M2M_EVENTS e WHERE e.GUID IN (SELECT TOP (@n) GUID FROM dbo.FTV_M2M ORDER BY `+strings.Replace(strings.TrimPrefix(timeCol, "m."), "NULL", "GUID", 1)+` DESC) ORDER BY e.EVENT_DATE`, sql.Named("n", limit)) + if err == nil { + for er.Next() { + var g string + var e depoQueueEvent + var at sql.NullTime + if er.Scan(&g, &e.Ref, &e.State, &e.Type, &at, &e.Codes, &e.Comment) == nil { + if at.Valid { + e.At = at.Time + } + if i, ok := idx[strings.ToUpper(g)]; ok { + out[i].Events = append(out[i].Events, e) + if strings.EqualFold(e.State, "TODO") { + out[i].Pending++ + } + if e.Type == "DEPO_ERROR" && e.Comment != "" { + out[i].Errors = append(out[i].Errors, e.Comment) + } + if e.At.After(out[i].LastAt) { + out[i].LastAt, out[i].LastEvent = e.At, e.Type + } + if e.At.After(sum.LastEventAt) { + sum.LastEventAt = e.At + } + } + } + } + er.Close() + } + } + if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") { + sum.HasHandbook = true + _ = db.QueryRowContext(ctx, "SELECT count(*) FROM dbo.FTV_M2M_HANDBOOK").Scan(&sum.Handbook) + if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") { + var t sql.NullTime + if db.QueryRowContext(ctx, "SELECT max(HANDBOOK_AT) FROM dbo.FTV_M2M_HANDBOOK").Scan(&t) == nil && t.Valid { + sum.HandbookAt = t.Time + } + } + } + for i := range out { + sum.Total++ + if out[i].Pending > 0 { + sum.Pending++ + } + if out[i].Doc != "" { + sum.WithDoc++ + } + if len(out[i].Errors) > 0 { + sum.Errors++ + } + } + sort.SliceStable(out, func(i, j int) bool { + ai, aj := out[i].CreatedAt, out[j].CreatedAt + if ai.IsZero() { + ai = out[i].LastAt + } + if aj.IsZero() { + aj = out[j].LastAt + } + return ai.After(aj) + }) + return out, sum, nil +} + +var errNoDepoTables = errNoDepo("в базе DEPO нет таблицы FTV_M2M — обмен ещё не настроен программистом Fancy") + +type errNoDepo string + +func (e errNoDepo) Error() string { return string(e) } + +// fancyQueue — GET /admin/fancy-queue: обмен с DEPO через FTV_M2M*. +func (h *setupHandlers) fancyQueue(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot().Fancy + var rows []depoQueueRow + var sum depoQueueSummary + var loadErr string + if s.Server == "" { + loadErr = "Подключение к базе DEPO (Fancy) не настроено — Настройка → Обмен Fancy" + } else if rs, sm, err := depoQueueLoad(r.Context(), h.rc, 100); err != nil { + loadErr = "не удалось прочитать трансферные таблицы: " + err.Error() + } else { + rows, sum = rs, sm + } + // ошибки обработчика и пользователь для EXECUTE AS — видны сразу на странице + var fails []depoJobFailure + runAsOK, runAsMsg := true, "" + if s.Server != "" && loadErr == "" { + if db, err := depoEventsDB(h.rc); err == nil { + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + fails, _ = depoJobFailures(ctx, db, 7, 50) + runAsOK, runAsMsg = depoRunAsCheck(ctx, db, s) + cancel() + db.Close() + } + } + data := struct { + page + Rows []depoQueueRow + Sum depoQueueSummary + Error string + Flash string + DB string + Fancy FancyMSSQLSettings + JobMode string + Failures []depoJobFailure + RunAs string + RunAsOK bool + RunAsMsg string + }{nowPage("Обмен с DEPO", "fancy"), rows, sum, loadErr, r.URL.Query().Get("flash"), s.DepoDB(), s, depoJobModeRu(s.DepoJobMode), fails, depoJobRunAs(s), runAsOK, runAsMsg} + render(w, h.tpl.a.fancyQueue, data) +} + +// fancyQueueRequeue — POST /admin/fancy-queue/requeue: вернуть записи с ошибкой обработчика в очередь. +func (h *setupHandlers) fancyQueueRequeue(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + msg := "" + db, err := depoEventsDB(h.rc) + if err == nil { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + var o, e int64 + o, e, err = depoRequeueFailures(ctx, db, 7) + cancel() + db.Close() + if err == nil { + msg = fmt.Sprintf("Возвращено в очередь обработчика: поручений %d, событий %d. Обновите страницу через 10–20 секунд.", o, e) + log.Printf("depo-events: записи с ошибкой возвращены в очередь: поручений %d, событий %d", o, e) + } + } + if err != nil { + msg = "Не удалось вернуть записи в очередь: " + quikErrorDetail(err) + } + http.Redirect(w, r, "/admin/fancy-queue?flash="+url.QueryEscape(msg), http.StatusSeeOther) +} + +func depoJobModeRu(m string) string { + switch m { + case "bj": + return "процедуру вызывает bj" + case "sql-auto": + return "SQL Agent (создан из bj)" + case "sql-manual": + return "SQL Agent (настроен вручную)" + } + return "не выбран" +} diff --git a/internal/lkgateway/depotables.go b/internal/lkgateway/depotables.go new file mode 100644 index 0000000..dfeeeb5 --- /dev/null +++ b/internal/lkgateway/depotables.go @@ -0,0 +1,431 @@ +package lkgateway + +// Трансферные таблицы обмена с DEPO — FTV_M2M, FTV_M2M_LINES, FTV_M2M_EVENTS, +// FTV_M2M_HANDBOOK — в базе DEPO клиента (Fancy.DepoDB). Структура повторяет +// таблицы разработчика Fancy (сняты с SQL002 15.09.2026) и описана один раз: +// из неё строится DDL для MSSQL и PostgreSQL. При установке таблицы создаёт +// либо bj (логином с правами, пароль не сохраняется), либо администратор СУБД +// по скрипту. DDL идемпотентный: недостающие таблицы, колонки и индексы +// добавляются, существующие данные не трогаются. + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" +) + +// depoDefaultDatabase — база DEPO, если в настройках не задана (наша копия на SQL002). +const depoDefaultDatabase = "BJ_DEPO_DATA" + +// DepoDB — имя базы депозитария DEPO на сервере Fancy. +func (s FancyMSSQLSettings) DepoDB() string { + if d := strings.TrimSpace(s.DepoDatabase); d != "" { + return d + } + return depoDefaultDatabase +} + +// IISMarkers — с чего начинается название брокерского договора ИИС в DEPO (OD_CLIENTS_PORTS.PORT_NUM). +func (s FancyMSSQLSettings) IISMarkers() []string { + var out []string + for _, m := range strings.Split(s.IISContractMarker, ",") { + if m = strings.TrimSpace(m); m != "" { + out = append(out, m) + } + } + if len(out) == 0 { + out = []string{"БРИИС"} + } + return out +} + +type depoCol struct { + Name, MSSQL, PG string + NotNull bool + DefMSSQL, DefPG string // выражение DEFAULT, "" — без значения по умолчанию +} + +func depoNull(name, ms, pg string) depoCol { return depoCol{Name: name, MSSQL: ms, PG: pg} } +func depoNotNull(name, ms, pg string) depoCol { + return depoCol{Name: name, MSSQL: ms, PG: pg, NotNull: true} +} + +func (c depoCol) def(ms, pg string) depoCol { + c.DefMSSQL, c.DefPG = ms, pg + return c +} + +type depoTableSpec struct { + Name string + Cols []depoCol // без ID: у всех таблиц это identity и первичный ключ + Indexes [][]string +} + +var ( + depoColState = depoNotNull("STATE", "varchar(64)", "varchar(64)").def("'TODO'", "'TODO'") + depoColTimeNew = depoNull("TimeNew", "datetime", "timestamp").def("GETDATE()", "LOCALTIMESTAMP") + depoColTimeBeg = depoNull("TimeBeg", "datetime", "timestamp") + depoColTimeEnd = depoNull("TimeEnd", "datetime", "timestamp") + depoColComment = depoNull("COMMENT", "varchar(max)", "text") + depoColGUID = depoNotNull("GUID", "uniqueidentifier", "uuid") +) + +var depoTableSpecs = []depoTableSpec{ + {Name: "FTV_M2M", Cols: []depoCol{ + depoColGUID, depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd, depoColComment, + depoNotNull("DIRECTION", "varchar(4)", "varchar(4)"), + depoNotNull("CONTRAGENT_M2M_CODE", "varchar(255)", "varchar(255)"), + depoNotNull("DEPONENT_NAME", "varchar(255)", "varchar(255)"), + depoNotNull("DEPONENT_DOC", "varchar(255)", "varchar(255)"), + depoNull("DOC", "int", "integer"), + depoNull("CONTRAGENT", "int", "integer"), + depoNull("DEPONENT", "int", "integer"), + }, Indexes: [][]string{{"GUID"}}}, + {Name: "FTV_M2M_LINES", Cols: []depoCol{ + depoColGUID, + depoNull("REFERENCE", "varchar(36)", "varchar(36)"), + depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd, + depoNull("ISIN", "varchar(12)", "varchar(12)"), + depoNull("SEC_CODE", "varchar(255)", "varchar(255)"), + depoNull("AMOUNT", "numeric(25,7)", "numeric(25,7)"), + depoNull("PLACE_NUMBER", "varchar(128)", "varchar(128)"), + depoNull("LINK_NUMBER", "varchar(64)", "varchar(64)"), + depoNull("DEPO_NUMBER", "varchar(128)", "varchar(128)"), + depoNull("DOC", "int", "integer"), + depoNull("DOL", "int", "integer"), + depoNull("SHARE", "int", "integer"), + depoNull("PLACE", "int", "integer"), + depoNull("DEPO", "int", "integer"), + depoColComment, + }, Indexes: [][]string{{"GUID"}}}, + {Name: "FTV_M2M_EVENTS", Cols: []depoCol{ + depoColGUID, + depoNull("REFERENCE", "varchar(36)", "varchar(36)"), + depoColState, depoColTimeNew, depoColTimeBeg, depoColTimeEnd, depoColComment, + depoNotNull("EVENT_TYPE", "varchar(64)", "varchar(64)"), + depoNotNull("EVENT_DATE", "datetime", "timestamp"), + depoNull("M2M_REJECT_CODE", "varchar(max)", "text"), + }, Indexes: [][]string{{"GUID"}, {"EVENT_TYPE", "STATE"}}}, + {Name: "FTV_M2M_HANDBOOK", Cols: []depoCol{ + depoNotNull("INN", "varchar(12)", "varchar(12)"), + depoNull("PARTICIPANT_CODE", "varchar(12)", "varchar(12)"), + depoNotNull("KIND", "varchar(8)", "varchar(8)"), + depoNull("NAME_FULL", "nvarchar(255)", "varchar(255)"), + depoNull("NAME_SHORT", "nvarchar(128)", "varchar(128)"), + depoNull("NAME_DISPLAY", "nvarchar(255)", "varchar(255)"), + depoNull("NAME_ENG", "nvarchar(255)", "varchar(255)"), + depoNull("HANDBOOK_AT", "datetime", "timestamp"), + depoNotNull("UPDATED_AT", "datetime", "timestamp").def("GETDATE()", "LOCALTIMESTAMP"), + depoNotNull("ACTIVE", "bit", "smallint").def("1", "1"), // smallint: bj пишет литерал 1 + }, Indexes: [][]string{{"INN"}}}, +} + +func depoTableSpecByName(name string) depoTableSpec { + for _, t := range depoTableSpecs { + if t.Name == name { + return t + } + } + return depoTableSpec{} +} + +func depoTableNames() []string { + out := make([]string, 0, len(depoTableSpecs)) + for _, t := range depoTableSpecs { + out = append(out, t.Name) + } + return out +} + +// colSQL — определение колонки. forAdd — для ALTER TABLE ADD в существующую +// таблицу: NOT NULL без значения по умолчанию туда не добавить, колонка будет NULL. +func (c depoCol) colSQL(pg, forAdd bool, table string) string { + typ, def := c.MSSQL, c.DefMSSQL + if pg { + typ, def = c.PG, c.DefPG + } + s := c.Name + " " + typ + if c.NotNull && (!forAdd || def != "") { + s += " NOT NULL" + } else { + s += " NULL" + } + if def != "" { + if pg { + s += " DEFAULT " + def + } else { + s += fmt.Sprintf(" CONSTRAINT DF_%s_%s DEFAULT (%s)", table, c.Name, def) + } + } + return s +} + +func (t depoTableSpec) createSQL(pg bool) string { + var b strings.Builder + if pg { + fmt.Fprintf(&b, "CREATE TABLE IF NOT EXISTS %s (\n ID integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", t.Name) + } else { + fmt.Fprintf(&b, "IF OBJECT_ID(N'dbo.%[1]s', N'U') IS NULL\nCREATE TABLE dbo.%[1]s (\n ID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_%[1]s PRIMARY KEY CLUSTERED", t.Name) + } + for _, c := range t.Cols { + b.WriteString(",\n " + c.colSQL(pg, false, t.Name)) + } + b.WriteString("\n)") + return b.String() +} + +// addColumnsSQL — недостающие колонки в таблице, созданной по более ранней структуре. +func (t depoTableSpec) addColumnsSQL(pg bool) string { + parts := make([]string, 0, len(t.Cols)) + for _, c := range t.Cols { + if pg { + parts = append(parts, "ADD COLUMN IF NOT EXISTS "+c.colSQL(true, true, t.Name)) + } else { + parts = append(parts, fmt.Sprintf("IF COL_LENGTH(N'dbo.%[1]s', N'%[2]s') IS NULL ALTER TABLE dbo.%[1]s ADD %[3]s;", t.Name, c.Name, c.colSQL(false, true, t.Name))) + } + } + if pg { + return "ALTER TABLE " + t.Name + "\n " + strings.Join(parts, ",\n ") + } + return strings.Join(parts, "\n") +} + +func (t depoTableSpec) indexSQL(pg bool) []string { + var out []string + for _, cols := range t.Indexes { + name := "IX_" + t.Name + "_" + strings.Join(cols, "_") + if pg { + out = append(out, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", strings.ToLower(name), t.Name, strings.Join(cols, ", "))) + } else { + out = append(out, fmt.Sprintf("IF INDEXPROPERTY(OBJECT_ID(N'dbo.%[1]s'), N'%[2]s', 'IndexID') IS NULL CREATE INDEX %[2]s ON dbo.%[1]s (%[3]s)", t.Name, name, strings.Join(cols, ", "))) + } + } + return out +} + +// depoTablesDDL — все стейтменты создания/догонки структуры, по одному на вызов Exec. +func depoTablesDDL(pg bool) []string { + var out []string + for _, t := range depoTableSpecs { + out = append(out, t.createSQL(pg), t.addColumnsSQL(pg)) + out = append(out, t.indexSQL(pg)...) + } + return out +} + +var depoLoginRe = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`) + +// depoTablesGrantSQL — права логину bj на чтение и запись трансферных таблиц. +func depoTablesGrantSQL(pg bool, user string) []string { + if !depoLoginRe.MatchString(user) { + return nil + } + if pg { + return []string{ + fmt.Sprintf(`GRANT USAGE ON SCHEMA public TO "%s"`, user), + fmt.Sprintf(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO "%s"`, user), // учётные таблицы DEPO (сверка) + fmt.Sprintf(`GRANT SELECT, INSERT, UPDATE, DELETE ON %s TO "%s"`, strings.Join(depoTableNames(), ", "), user), + } + } + out := []string{ + fmt.Sprintf("IF USER_ID(N'%[1]s') IS NULL CREATE USER [%[1]s] FOR LOGIN [%[1]s]", user), + // чтение учётных таблиц DEPO (лица, счета, остатки, опции) — для сверки заявок + fmt.Sprintf("IF IS_ROLEMEMBER(N'db_datareader', N'%[1]s') = 0 ALTER ROLE db_datareader ADD MEMBER [%[1]s]", user), + } + for _, n := range depoTableNames() { + out = append(out, fmt.Sprintf("GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.%s TO [%s]", n, user)) + } + return out +} + +func depoTablesFileName(f FancyMSSQLSettings) string { + if f.IsPostgres() { + return "bj-depo-tables-postgres.sql" + } + return "bj-depo-tables-mssql.sql" +} + +// depoTablesScript — скрипт для администратора СУБД (вариант «по скрипту»). +func depoTablesScript(f FancyMSSQLSettings) string { + pg := f.IsPostgres() + db, user := f.DepoDB(), strings.TrimSpace(f.User) + sep := "\nGO\n\n" + var b strings.Builder + if pg { + sep = ";\n\n" + fmt.Fprintf(&b, "-- Bridge&Join: трансферные таблицы обмена с DEPO. PostgreSQL 10 и новее.\n-- Выполнить в базе %[1]s владельцем базы или суперпользователем, например:\n-- psql -h <сервер> -U postgres -d %[1]s -f %[2]s\n", db, depoTablesFileName(f)) + } else { + fmt.Fprintf(&b, "-- Bridge&Join: трансферные таблицы обмена с DEPO. Microsoft SQL Server.\n-- Выполнить в SSMS или sqlcmd под логином с ролью db_owner в базе %s\n-- (роль нужна, чтобы добавить логин bj в db_datareader).\n", db) + } + b.WriteString("-- Скрипт можно запускать повторно: добавляются только недостающие таблицы, колонки и индексы, данные не меняются.\n\n") + if !pg { + fmt.Fprintf(&b, "USE [%s];%s", db, sep) + } + for _, s := range depoTablesDDL(pg) { + b.WriteString(s + sep) + } + if grants := depoTablesGrantSQL(pg, user); len(grants) > 0 { + fmt.Fprintf(&b, "-- Права логину bj (%s): чтение и запись трансферных таблиц.\n", user) + for _, s := range grants { + b.WriteString(s + sep) + } + } else { + b.WriteString("-- Права логину bj: выдайте SELECT, INSERT, UPDATE, DELETE на таблицы выше (логин bj в настройках не указан).\n\n") + } + b.WriteString("-- Кроме того, логину bj нужно право SELECT на учётные таблицы DEPO (OD_CLIENTS, OD_CLIENTS_PORTS, OD_RESTS, OD_VALUES и др.) для сверки переводов.\n") + return b.String() +} + +func depoTableVisible(ctx context.Context, db *sql.DB, pg bool, name string) bool { + q, arg := "SELECT CASE WHEN OBJECT_ID(@p1, 'U') IS NULL THEN 0 ELSE 1 END", "dbo."+name + if pg { + q, arg = "SELECT to_regclass($1) IS NOT NULL", name + } + var ok bool + return db.QueryRowContext(ctx, q, arg).Scan(&ok) == nil && ok +} + +func depoMissingColumns(ctx context.Context, db *sql.DB, pg bool, t depoTableSpec) []string { + q, arg := "SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(@p1)", "dbo."+t.Name + if pg { + q, arg = "SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = lower($1)", t.Name + } + rows, err := db.QueryContext(ctx, q, arg) + if err != nil { + return nil + } + defer rows.Close() + have := map[string]bool{} + for rows.Next() { + var n string + if rows.Scan(&n) == nil { + have[strings.ToUpper(n)] = true + } + } + var miss []string + for _, c := range append([]depoCol{{Name: "ID"}}, t.Cols...) { + if !have[strings.ToUpper(c.Name)] { + miss = append(miss, c.Name) + } + } + return miss +} + +func depoStmtHead(s string) string { + s, _, _ = strings.Cut(strings.TrimSpace(s), "\n") + if len(s) > 70 { + s = s[:70] + "…" + } + return s +} + +func depoTablesRightsHint(f FancyMSSQLSettings, login string, err error) string { + m := strings.ToLower(err.Error()) + if !strings.Contains(m, "permission") && !strings.Contains(m, "denied") && !strings.Contains(m, "разрешени") && !strings.Contains(m, "запрещ") { + return "" + } + if f.IsPostgres() { + return fmt.Sprintf(". У роли %s нет права создавать таблицы в базе %s: укажите владельца базы или суперпользователя либо выберите вариант «по скрипту»", login, f.DepoDB()) + } + return fmt.Sprintf(". У логина %s нет права создавать таблицы в базе %s: нужна роль db_owner (или db_ddladmin, а для выдачи прав bj ещё db_securityadmin) либо вариант «по скрипту»", login, f.DepoDB()) +} + +// depoTablesCreate — создать таблицы в базе DEPO. adminUser/adminPass — логин с правами +// (используется один раз, не сохраняется); пусто — логин bj из настроек. Если логин +// другой, bj выдаёт своему логину права на таблицы. +func depoTablesCreate(ctx context.Context, f FancyMSSQLSettings, adminUser, adminPass string) (string, error) { + pg := f.IsPostgres() + conn, grantTo := f, "" + if u := strings.TrimSpace(adminUser); u != "" { + conn.User, conn.Password = u, adminPass + if !strings.EqualFold(u, strings.TrimSpace(f.User)) { + grantTo = strings.TrimSpace(f.User) + } + } + db, err := fancyOpen(conn, f.DepoDB()) + if err != nil { + return "", err + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + return "", fmt.Errorf("подключение к базе %s логином %s: %s", f.DepoDB(), conn.User, quikErrorDetail(err)) + } + existed := 0 + for _, t := range depoTableSpecs { + if depoTableVisible(ctx, db, pg, t.Name) { + existed++ + } + } + for _, s := range depoTablesDDL(pg) { + if _, err := db.ExecContext(ctx, s); err != nil { + return "", fmt.Errorf("%s → %s%s", depoStmtHead(s), quikErrorDetail(err), depoTablesRightsHint(f, conn.User, err)) + } + } + msg := fmt.Sprintf("в базе %s создано таблиц: %d, уже были: %d", f.DepoDB(), len(depoTableSpecs)-existed, existed) + if grantTo != "" { + grants := depoTablesGrantSQL(pg, grantTo) + if grants == nil { + return msg + "; права логину " + grantTo + " не выданы: недопустимые символы в имени", nil + } + for _, s := range grants { + if _, err := db.ExecContext(ctx, s); err != nil { + return "", fmt.Errorf("%s, но выдать права логину %s не удалось: %s → %s", msg, grantTo, depoStmtHead(s), quikErrorDetail(err)) + } + } + msg += ", права логину " + grantTo + " выданы" + } + return msg, nil +} + +// depoTablesCheck — проверка логином bj: таблицы видны, колонки на месте, есть права на запись. +func depoTablesCheck(ctx context.Context, f FancyMSSQLSettings) (string, bool) { + pg := f.IsPostgres() + db, err := fancyOpen(f, f.DepoDB()) + if err != nil { + return "подключение: " + err.Error(), false + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + return fmt.Sprintf("нет подключения к базе %s: %s", f.DepoDB(), quikErrorDetail(err)), false + } + var problems []string + for _, t := range depoTableSpecs { + if !depoTableVisible(ctx, db, pg, t.Name) { + problems = append(problems, "нет таблицы "+t.Name+" (или у логина "+f.User+" нет к ней доступа)") + continue + } + var lacks []string + for _, p := range []string{"SELECT", "INSERT", "UPDATE", "DELETE"} { + q, arg := "SELECT ISNULL(HAS_PERMS_BY_NAME(@p1, 'OBJECT', @p2), 0)", "dbo."+t.Name + if pg { + q, arg = "SELECT has_table_privilege($1, $2)", t.Name + } + var has bool + if db.QueryRowContext(ctx, q, arg, p).Scan(&has) != nil || !has { + lacks = append(lacks, p) + } + } + if len(lacks) > 0 { + problems = append(problems, fmt.Sprintf("у логина %s нет прав %s на %s", f.User, strings.Join(lacks, "/"), t.Name)) + } + if miss := depoMissingColumns(ctx, db, pg, t); len(miss) > 0 { + problems = append(problems, t.Name+": нет колонок "+strings.Join(miss, ", ")) + } + } + if len(problems) > 0 { + return strings.Join(problems, "; "), false + } + return fmt.Sprintf("в базе %s все %d таблицы на месте, колонки совпадают, у логина %s есть SELECT/INSERT/UPDATE/DELETE", f.DepoDB(), len(depoTableSpecs), f.User), true +} + +// DepoTablesView — данные карточки «Трансферные таблицы DEPO». +type DepoTablesView struct { + DB, User, Script, ScriptFile string + Postgres bool +} + +func depoTablesView(f FancyMSSQLSettings) DepoTablesView { + return DepoTablesView{DB: f.DepoDB(), User: f.User, Script: depoTablesScript(f), ScriptFile: depoTablesFileName(f), Postgres: f.IsPostgres()} +} diff --git a/internal/lkgateway/depotables_live_test.go b/internal/lkgateway/depotables_live_test.go new file mode 100644 index 0000000..ec63da4 --- /dev/null +++ b/internal/lkgateway/depotables_live_test.go @@ -0,0 +1,128 @@ +//go:build live + +package lkgateway + +// Живая проверка создания трансферных таблиц DEPO. +// PostgreSQL: локальный сервер (fancy_user/fancy_test), тестовая база создаётся и удаляется. +// MSSQL: база DEPO из настроек bj — только разбор DDL (SET NOEXEC ON) и проверка, без изменений. +// go test -tags live -run TestDepoTablesLive ./internal/lkgateway/ ; MSSQL — от bj (читает setup.json). + +import ( + "context" + "os" + "testing" + "time" +) + +// BJ_PG_ADMIN/BJ_PG_ADMIN_PASS — роль с CREATEDB (создаёт базу и таблицы, выдаёт права); +// BJ_PG_USER/BJ_PG_PASS — «логин bj» без прав: проверка идёт от его имени. +func TestDepoTablesLivePostgres(t *testing.T) { + adminUser, adminPass := os.Getenv("BJ_PG_ADMIN"), os.Getenv("BJ_PG_ADMIN_PASS") + if adminUser == "" || os.Getenv("BJ_PG_USER") == "" { + t.Skip("не заданы BJ_PG_ADMIN / BJ_PG_USER") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + f := FancyMSSQLSettings{DBType: "postgres", Server: "localhost", Port: 5432, User: os.Getenv("BJ_PG_USER"), Password: os.Getenv("BJ_PG_PASS"), DepoDatabase: "bj_depo_tables_test"} + admin := f + admin.User, admin.Password = adminUser, adminPass + if conn, err := fancyOpen(admin, ""); err != nil { + t.Fatal(err) + } else { + _, err = conn.ExecContext(ctx, `CREATE DATABASE bj_depo_tables_test`) + conn.Close() + if err != nil { + t.Fatalf("создание тестовой базы: %v", err) + } + } + defer func() { + conn, _ := fancyOpen(admin, "") + defer conn.Close() + if _, err := conn.ExecContext(context.Background(), `DROP DATABASE IF EXISTS bj_depo_tables_test`); err != nil { + t.Logf("удаление тестовой базы: %v", err) + } + }() + if msg, ok := depoTablesCheck(ctx, f); ok { + t.Fatalf("до создания проверка должна падать: %s", msg) + } else { + t.Logf("до создания: %s", msg) + } + for i := 1; i <= 2; i++ { + msg, err := depoTablesCreate(ctx, f, adminUser, adminPass) + if err != nil { + t.Fatalf("создание, проход %d: %v", i, err) + } + t.Logf("проход %d: %s", i, msg) + } + msg, ok := depoTablesCheck(ctx, f) + t.Logf("проверка: %v %s", ok, msg) + if !ok { + t.Fatal("проверка не прошла") + } + db, err := fancyOpen(f, f.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + g := "5325b860-3265-49b6-986d-fa84fb41fe27" + for _, q := range []string{ + `INSERT INTO FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, COMMENT) VALUES ('` + g + `', 'IN', 'MC0034900000', 'Погонщикова Виктория Олеговна', '4500 000000', 'тест')`, + `INSERT INTO FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, LINK_NUMBER) VALUES ('` + g + `', 'M2MTEST0001', 'RU000A107UL4', 'RU000A107UL4', 18824, 1.5, '36MC0413600000F00', '')`, + `INSERT INTO FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT) VALUES ('` + g + `', NULL, 'TODO', 'BJ_RECEIVED', LOCALTIMESTAMP, NULL, 'тест')`, + `INSERT INTO FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_SHORT, HANDBOOK_AT, UPDATED_AT, ACTIVE) VALUES ('7707025725', 'MC0005600000', 'DEPO', 'ЦентроКредит', LOCALTIMESTAMP, LOCALTIMESTAMP, 1)`, + } { + if _, err := db.ExecContext(ctx, q); err != nil { + t.Fatalf("%s: %v", depoStmtHead(q), err) + } + } + var state string + var tn time.Time + if err := db.QueryRowContext(ctx, `SELECT STATE, TimeNew FROM FTV_M2M WHERE GUID = $1`, g).Scan(&state, &tn); err != nil { + t.Fatal(err) + } + t.Logf("значения по умолчанию в FTV_M2M: STATE=%s TimeNew=%s", state, tn.Format("15:04:05")) + if state != "TODO" { + t.Fatalf("STATE по умолчанию %q, ожидали TODO", state) + } +} + +func TestDepoTablesLiveMSSQL(t *testing.T) { + if _, err := os.Stat("/var/lib/bj/.bj/setup.json"); err != nil { + t.Skip("нет setup.json") + } + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Skip(err) + } + f := rc.Snapshot().Fancy + if f.IsPostgres() || f.Server == "" { + t.Skip("в настройках не MSSQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + db, err := fancyOpen(f, f.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn, err := db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, "SET NOEXEC ON"); err != nil { + t.Fatal(err) + } + stmts := append(depoTablesDDL(false), depoTablesGrantSQL(false, f.User)...) + for _, s := range stmts { + if _, err := conn.ExecContext(ctx, s); err != nil { + t.Errorf("разбор %s: %v", depoStmtHead(s), err) + } + } + if _, err := conn.ExecContext(ctx, "SET NOEXEC OFF"); err != nil { + t.Fatal(err) + } + t.Logf("разобрано стейтментов MSSQL: %d", len(stmts)) + msg, ok := depoTablesCheck(ctx, f) + t.Logf("проверка логином %s в %s: %v — %s", f.User, f.DepoDB(), ok, msg) +} diff --git a/internal/lkgateway/depotables_test.go b/internal/lkgateway/depotables_test.go new file mode 100644 index 0000000..b9e87cf --- /dev/null +++ b/internal/lkgateway/depotables_test.go @@ -0,0 +1,38 @@ +package lkgateway + +import ( + "strings" + "testing" +) + +func TestDepoTablesScript(t *testing.T) { + ms := depoTablesScript(FancyMSSQLSettings{User: "bj_user", DepoDatabase: "DEPO"}) + for _, want := range []string{"USE [DEPO];", "CREATE TABLE dbo.FTV_M2M (", "CREATE TABLE dbo.FTV_M2M_HANDBOOK (", "\nGO\n", + "IF COL_LENGTH(N'dbo.FTV_M2M_LINES', N'SHARE') IS NULL ALTER TABLE", "GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.FTV_M2M_EVENTS TO [bj_user]", + "STATE varchar(64) NOT NULL CONSTRAINT DF_FTV_M2M_STATE DEFAULT ('TODO')"} { + if !strings.Contains(ms, want) { + t.Errorf("MSSQL: нет %q", want) + } + } + if n := strings.Count(ms, "CREATE TABLE "); n != len(depoTableSpecs) { + t.Errorf("MSSQL: CREATE TABLE %d, ожидали %d", n, len(depoTableSpecs)) + } + + pg := depoTablesScript(FancyMSSQLSettings{DBType: "postgres", User: "bj_user"}) + for _, want := range []string{"CREATE TABLE IF NOT EXISTS FTV_M2M (", "ID integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY", + "ADD COLUMN IF NOT EXISTS SHARE integer NULL", `GRANT SELECT, INSERT, UPDATE, DELETE ON FTV_M2M, FTV_M2M_LINES, FTV_M2M_EVENTS, FTV_M2M_HANDBOOK TO "bj_user"`, + "-d BJ_DEPO_DATA"} { + if !strings.Contains(pg, want) { + t.Errorf("PostgreSQL: нет %q", want) + } + } + for _, bad := range []string{"\nGO\n", "dbo.", "GETDATE", "varchar(max)", "uniqueidentifier"} { + if strings.Contains(pg, bad) { + t.Errorf("PostgreSQL: лишнее %q", bad) + } + } + + if g := depoTablesGrantSQL(false, "bj]; DROP TABLE x;--"); g != nil { + t.Errorf("логин с недопустимыми символами не должен попадать в GRANT: %v", g) + } +} diff --git a/internal/lkgateway/export.go b/internal/lkgateway/export.go new file mode 100644 index 0000000..e18cab8 --- /dev/null +++ b/internal/lkgateway/export.go @@ -0,0 +1,444 @@ +package lkgateway + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "html" + "net/http" + "path" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// export.go — выгрузка доказательной базы по заявкам для контролирующих органов. +// • по одной заявке: ZIP {отчёт.html + оригинальные request.xml/response.xml}; +// • по клиенту: ZIP {сводка.csv + папка на каждую заявку с тем же комплектом}. +// Оригинальные XML сохраняются в исходной кодировке windows-1251 — дословно как +// отправлено в НРД и получено от него (юридически точная копия). + +// exportClaim отдаёт ZIP-пакет доказательств по одной заявке. +func (a *admin) exportClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) { + id = path.Base(id) + view, err := svc.GetClaim(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + view.Timeline = buildClaimTimeline(r.Context(), view) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + writeClaimBundle(r.Context(), zw, svc, view, "") + if err := zw.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fname := fmt.Sprintf("zayavka-%s.zip", shortID(view.ID)) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`) + _, _ = w.Write(buf.Bytes()) +} + +// exportClientClaims отдаёт ZIP со всеми заявками клиента (?investor=). +func (a *admin) exportClientClaims(w http.ResponseWriter, r *http.Request, svc *Service) { + investor := strings.TrimSpace(r.URL.Query().Get("investor")) + if investor == "" { + http.Error(w, "не указан клиент (investor)", http.StatusBadRequest) + return + } + filter := m2mcore.Filter{InvestorID: investor, Limit: 5000} + from, to := parseExportPeriod(r.URL.Query().Get("from"), r.URL.Query().Get("to")) + filter.CreatedFrom, filter.CreatedTo = from, to + pageData, err := svc.ListClaims(r.Context(), filter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(pageData.Items) == 0 { + http.Error(w, "у клиента нет заявок за указанный период", http.StatusNotFound) + return + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // Сводка CSV по всем заявкам клиента. + var csv bytes.Buffer + csv.WriteString("\xEF\xBB\xBF") // BOM — чтобы Excel открыл в UTF-8 + csv.WriteString("№ заявки;GUID НРД;Дата;Статус;ФИО инвестора;Передающий ИНН;Зачисляющий ИНН;Бумаг;Итог НРД\n") + for _, v := range pageData.Items { + csv.WriteString(strings.Join([]string{ + csvCell(v.ID), csvCell(string(v.M2MGUID)), + csvCell(v.CreatedAt.Format("02.01.2006 15:04:05")), + csvCell(russianState(v.Status)), + csvCell(fio(v.Investor)), + csvCell(v.TransferringDepositoryINN), csvCell(v.ReceivingDepositoryINN), + csvCell(fmt.Sprintf("%d", len(v.Securities))), + csvCell(nsdOutcomeSummary(v)), + }, ";") + "\n") + } + if f, e := zw.Create("svodka.csv"); e == nil { + _, _ = f.Write(csv.Bytes()) + } + + // Полный комплект по каждой заявке — в отдельной папке. + for _, v := range pageData.Items { + v.Timeline = buildClaimTimeline(r.Context(), v) + writeClaimBundle(r.Context(), zw, svc, v, "zayavka-"+shortID(v.ID)+"/") + } + if err := zw.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + period := "" + if from != nil { + period = "-s-" + from.Format("2006-01-02") + } + if to != nil { + period += "-po-" + to.Format("2006-01-02") + } + fname := fmt.Sprintf("klient-%s-zayavki%s.zip", shortID(investor), period) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`) + _, _ = w.Write(buf.Bytes()) +} + +// parseExportPeriod разбирает даты периода (YYYY-MM-DD). Пустые → nil (без +// ограничения с этой стороны). Конец периода — включительно (до конца суток). +func parseExportPeriod(fromStr, toStr string) (*time.Time, *time.Time) { + var from, to *time.Time + if t, err := time.Parse("2006-01-02", strings.TrimSpace(fromStr)); err == nil { + from = &t + } + if t, err := time.Parse("2006-01-02", strings.TrimSpace(toStr)); err == nil { + end := t.Add(24*time.Hour - time.Second) // конец суток включительно + to = &end + } + return from, to +} + +// writeClaimBundle пишет в zip комплект по одной заявке: отчёт + оригинальные XML +// (request, а также response ИЛИ decision — что есть). +func writeClaimBundle(ctx context.Context, zw *zip.Writer, svc *Service, v ClaimView, prefix string) { + reqXML, _ := svc.ClaimRawXML(ctx, v.ID, "request") + respXML, _ := svc.ClaimRawXML(ctx, v.ID, "response") + decXML, _ := svc.ClaimRawXML(ctx, v.ID, "decision") + if f, e := zw.Create(prefix + "otchet.html"); e == nil { + outXML := respXML + if len(outXML) == 0 { + outXML = decXML + } + _, _ = f.Write(claimReportHTML(v, reqXML, outXML)) + } + if len(reqXML) > 0 { + if f, e := zw.Create(prefix + "request.xml"); e == nil { + _, _ = f.Write(reqXML) + } + } + if len(respXML) > 0 { + if f, e := zw.Create(prefix + "response.xml"); e == nil { + _, _ = f.Write(respXML) + } + } + if len(decXML) > 0 { + if f, e := zw.Create(prefix + "decision.xml"); e == nil { + _, _ = f.Write(decXML) + } + } +} + +// exportCompare — ZIP-сравнение двух заявок для обращения в НРД: сопроводительное +// письмо + комплект по каждой заявке. Параметры ?a=&b=. +func (a *admin) exportCompare(w http.ResponseWriter, r *http.Request, svc *Service) { + idA := strings.TrimSpace(r.URL.Query().Get("a")) + idB := strings.TrimSpace(r.URL.Query().Get("b")) + if idA == "" || idB == "" { + http.Error(w, "нужны обе заявки: ?a=&b=", http.StatusBadRequest) + return + } + va, err := svc.GetClaim(r.Context(), idA) + if err != nil { + http.Error(w, "заявка A: "+err.Error(), http.StatusNotFound) + return + } + vb, err := svc.GetClaim(r.Context(), idB) + if err != nil { + http.Error(w, "заявка B: "+err.Error(), http.StatusNotFound) + return + } + va.Timeline = buildClaimTimeline(r.Context(), va) + vb.Timeline = buildClaimTimeline(r.Context(), vb) + + // Идентичны ли запросы (без учёта GUID/timestamp/референсов)? + reqA, _ := svc.ClaimRawXML(r.Context(), va.ID, "request") + reqB, _ := svc.ClaimRawXML(r.Context(), vb.ID, "request") + identical := normalizeReq(reqA) == normalizeReq(reqB) && len(reqA) > 0 + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + if f, e := zw.Create("soprovoditelnoe-pismo.txt"); e == nil { + _, _ = f.Write(compareNote(va, vb, identical)) + } + writeClaimBundle(r.Context(), zw, svc, va, "zayavka-A-"+shortID(va.ID)+"/") + writeClaimBundle(r.Context(), zw, svc, vb, "zayavka-B-"+shortID(vb.ID)+"/") + if err := zw.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fname := fmt.Sprintf("sravnenie-%s-vs-%s.zip", shortID(va.ID), shortID(vb.ID)) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`) + _, _ = w.Write(buf.Bytes()) +} + +// normalizeReq убирает содержимое уникальных полей (GUID/timestamp/референсы), +// чтобы сравнивать запросы по существу. +func normalizeReq(xml []byte) string { + s := string(nsdxml.DecodeWindows1251(xml)) + for _, tag := range []string{"GUID", "CreationTimestamp", "ReferenceId", "ReferenceID"} { + s = stripTagContent(s, tag) + } + return s +} + +// stripTagContent очищает текст между открывающим тегом, оканчивающимся на +// "tag>", и следующим "<" (т.е. содержимое элемента) — для всех вхождений. +func stripTagContent(s, tag string) string { + needle := tag + ">" + var b strings.Builder + for { + i := strings.Index(s, needle) + if i < 0 { + b.WriteString(s) + break + } + cut := i + len(needle) + b.WriteString(s[:cut]) // до конца открывающего тега включительно + rest := s[cut:] + if j := strings.IndexByte(rest, '<'); j >= 0 { + s = rest[j:] // пропускаем содержимое до следующего тега + } else { + s = rest + } + } + return b.String() +} + +// compareNote формирует сопроводительное письмо для НРД. +func compareNote(va, vb ClaimView, identical bool) []byte { + var b strings.Builder + b.WriteString("Обращение в НРД (сервис MOEX МОСТ, M2M) — сравнение двух заявок\n") + b.WriteString("=================================================================\n\n") + if identical { + b.WriteString("Ситуация: идентичный по составу запрос M2MTransferRequest ранее был\n") + b.WriteString("ПОДТВЕРЖДЁН, а теперь ОТКЛОНЯЕТСЯ. Просим пояснить причину и как её устранить.\n\n") + } else { + b.WriteString("Ситуация: сравнение двух заявок M2MTransferRequest с разным результатом.\n\n") + } + writeOne := func(tag string, v ClaimView) { + b.WriteString(tag + "\n") + b.WriteString(" Внутренний ID bj : " + v.ID + "\n") + b.WriteString(" GUID сообщения : " + string(v.M2MGUID) + "\n") + b.WriteString(" Дата отправки : " + v.CreatedAt.Format("02.01.2006 15:04:05") + " МСК\n") + b.WriteString(" Статус : " + russianState(v.Status) + "\n") + b.WriteString(" Инвестор : " + fio(v.Investor) + "\n") + b.WriteString(" Депозитарии : передающий ИНН " + v.TransferringDepositoryINN + " → зачисляющий ИНН " + v.ReceivingDepositoryINN + "\n") + b.WriteString(" Итог НРД : " + nsdOutcomeSummary(v) + "\n") + if v.M2MResponse != nil { + for _, e := range v.M2MResponse.Responses { + if e.Code != "" { + b.WriteString(" код " + e.Code + " — " + m2mCodeText(e.Code) + e.Text + "\n") + } + } + } + b.WriteString("\n") + } + writeOne("ЗАЯВКА A:", va) + writeOne("ЗАЯВКА B:", vb) + if identical { + b.WriteString("ВАЖНО: приложенные request.xml обеих заявок ПОЛНОСТЬЮ СОВПАДАЮТ по составу\n") + b.WriteString("(отличаются только уникальные GUID, CreationTimestamp и ReferenceId, что нормально).\n") + b.WriteString("Состав, реквизиты депозитариев, ценные бумаги и место расчётов — идентичны.\n\n") + } + b.WriteString("Приложения: по каждой заявке — request.xml (отправленный запрос) и\n") + b.WriteString("response.xml/decision.xml (ответ/решение НРД), а также отчёт otchet.html.\n") + b.WriteString("Все XML — в исходной кодировке windows-1251, дословно.\n\n") + b.WriteString("Просьба: пояснить причину изменения результата и порядок устранения\n") + b.WriteString("(в т.ч. если это связано с учётом тестовых ценных бумаг на стороне НРД).\n") + return []byte(b.String()) +} + +// claimReportHTML формирует печатный отчёт-доказательство по заявке (self-contained, +// UTF-8). Сырые XML показаны дословно (декодированы из windows-1251 для чтения); +// юридически точные копии — в приложенных .xml. +func claimReportHTML(v ClaimView, reqXML, respXML []byte) []byte { + var b strings.Builder + esc := html.EscapeString + b.WriteString(``) + b.WriteString(`Заявка M2M ` + esc(shortID(v.ID)) + ``) + + // Шапка с официальным логотипом сервиса MOEX МОСТ (НРД). + b.WriteString(`
    ` + moexMostLogoSVG + + `Сервис быстрых переводов ценных бумаг
    MOEX МОСТ · НКО АО НРД
    ` + orgLogoIMG() + `
    `) + b.WriteString(`

    Заявка на перевод ценных бумаг (M2M / MOEX МОСТ)

    `) + b.WriteString(`
    Внутренний идентификатор bj: ` + esc(v.ID) + ` · Сформировано: ` + + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + ` МСК
    `) + + // Реквизиты заявки. + b.WriteString(`

    Реквизиты заявки

    `) + row := func(k, val string) { b.WriteString(``) } + row("GUID сообщения в НРД", string(v.M2MGUID)) + if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok { + row("Происхождение", fmt.Sprintf("Личный кабинет ЕСИА Финанс, заявка №%d", lkID)) + } + row("Статус", russianState(v.Status)) + row("Создана", v.CreatedAt.Format("02.01.2006 15:04:05")) + row("Обновлена", v.UpdatedAt.Format("02.01.2006 15:04:05")) + row("Инвестор", fio(v.Investor)) + row("Документ инвестора", docLabel(v.Investor.Document)) + row("Передающий депозитарий (ИНН)", v.TransferringDepositoryINN) + row("Зачисляющий депозитарий (ИНН)", v.ReceivingDepositoryINN) + b.WriteString(`
    ` + k + `` + esc(val) + `
    `) + + // Ценные бумаги. + b.WriteString(`

    Ценные бумаги

    `) + for _, s := range v.Securities { + code := s.SecurityDetails.ISIN + if code == "" { + code = s.SecurityCode + } + qty := fmt.Sprintf("%d", s.Quantity.Whole) + if s.Quantity.Fractional != "" { + qty = s.Quantity.Fractional + } + b.WriteString(``) + } + b.WriteString(`
    Код/ISINКоличество
    ` + esc(code) + `` + esc(qty) + `
    `) + + // Путь заявки (таймлайн). + b.WriteString(`

    Путь заявки (хронология)

      `) + for _, e := range v.Timeline { + line := e.Time.Format("02.01.2006 15:04:05") + " МСК — " + esc(e.Title) + "" + if e.Detail != "" { + line += `
      ` + esc(e.Detail) + `
      ` + } + b.WriteString(`
    1. ` + line + `
    2. `) + } + b.WriteString(`
    `) + + // Итог НРД. + if v.M2MDecision != nil || v.M2MResponse != nil { + b.WriteString(`

    Ответ НРД

    `) + if v.M2MResponse != nil { + b.WriteString(`

    Статус сервиса МОСТ: ` + esc(v.M2MResponse.StatusCode) + `

    `) + for _, e := range v.M2MResponse.Responses { + b.WriteString(``) + } + b.WriteString(`
    ` + esc(e.Code) + `` + esc(m2mCodeText(e.Code)) + esc(e.Text) + `
    `) + } + if v.M2MDecision != nil { + b.WriteString(``) + for _, s := range v.M2MDecision.Securities { + b.WriteString(``) + } + b.WriteString(`
    Референс ЦБРешение
    ` + esc(s.ReferenceID) + `` + esc(russianOutcome(s.Outcome)) + ` ` + esc(rejectCodesHuman(s.RejectCodes)) + `
    `) + } + } + + // Дословные XML. + if len(reqXML) > 0 { + if fields := renderXMLFieldsTable(string(nsdxml.DecodeWindows1251(reqXML))); fields != "" { + b.WriteString(`

    Все поля запроса (разбор XML)

    ` + fields) + } + b.WriteString(`

    Отправленный запрос в НРД (дословно)

    Точная копия — в приложенном файле request.xml (кодировка windows-1251).

    ` +
    +			esc(string(nsdxml.DecodeWindows1251(reqXML))) + `
    `) + } + if len(respXML) > 0 { + b.WriteString(`

    Ответ НРД (дословно)

    Точная копия — в приложенном файле response.xml.

    ` +
    +			esc(string(nsdxml.DecodeWindows1251(respXML))) + `
    `) + } + + b.WriteString(`

    Документ сформирован автоматически системой Bridge-and-Join-s (bj) на основании записей журнала обмена с НРД. ` + + `Приложенные XML-файлы являются дословными копиями сообщений, которыми bj обменивался с сервисом MOEX МОСТ (НРД).

    `) + b.WriteString(``) + return []byte(b.String()) +} + +// _ = context пометка не нужна — context используется в сигнатурах. + +// --- вспомогательное --- + +func shortID(id string) string { + if i := strings.IndexByte(id, '-'); i > 0 { + return id[:i] + } + if len(id) > 8 { + return id[:8] + } + return id +} + +func fio(inv Investor) string { + return strings.TrimSpace(inv.LastName + " " + inv.FirstName + " " + inv.MiddleName) +} + +func docLabel(d Document) string { + t := d.DocumentType + if t == "21" { + t = "Паспорт РФ" + } + s := t + if d.Series != "" { + s += " серия " + d.Series + } + if d.Number != "" { + s += " № " + d.Number + } + return strings.TrimSpace(s) +} + +func nsdOutcomeSummary(v ClaimView) string { + if v.M2MDecision != nil { + conf, rej := 0, 0 + for _, s := range v.M2MDecision.Securities { + if s.Outcome == "confirmed" { + conf++ + } else { + rej++ + } + } + return fmt.Sprintf("Решение: принято %d, отклонено %d", conf, rej) + } + if v.M2MResponse != nil { + var codes []string + for _, e := range v.M2MResponse.Responses { + if e.Code != "" { + codes = append(codes, e.Code) + } + } + return v.M2MResponse.StatusCode + " " + strings.Join(codes, " ") + } + return "—" +} + +func csvCell(s string) string { + if strings.ContainsAny(s, ";\"\n") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} diff --git a/internal/lkgateway/fancy_bundle.go b/internal/lkgateway/fancy_bundle.go new file mode 100644 index 0000000..56e8a77 --- /dev/null +++ b/internal/lkgateway/fancy_bundle.go @@ -0,0 +1,652 @@ +package lkgateway + +// fancy_bundle.go — полный «эталон» тестового взаимодействия с контрагентом (Банк +// ЗЕНИТ) для программиста Fancy: ОБЕ стороны обмена — +// • входящие (контрагент → мы): что пришло + какое решение мы дали руками; +// • исходящие (мы → контрагент): что мы отправили + что нам ответил контрагент. +// По каждому переводу — дословные XML (request/response/decision) и пояснение. +// Цель: настроить Fancy так, чтобы она формировала те же запросы и те же ответы. + +import ( + "archive/zip" + "bytes" + "fmt" + "html" + "net/http" + "sort" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// fancyRule — правило ответа Fancy по коду решения (что Fancy должна проверить). +var fancyRule = map[string]string{ + "": "Все проверки пройдены: бумага обслуживается зачисляющим, счёт зачисления определён и является торговым, инвестор найден, ограничений нет → ПРИНЯТЬ (Confirmation).", + "M2M02": "Счёт депо для зачисления не может быть определён (у инвестора нет счёта по умолчанию, а он требуется) → отказ M2M02.", + "M2M03": "Счёт депо зачисления не является торговым → отказ M2M03.", + "M2M04": "Инвестор не найден у зачисляющего депозитария → отказ M2M04.", + "M2M05": "Зачисляющий не обслуживает данную бумагу (например, Газпром RU0007661625) → отказ M2M05.", + "M2M06": "По счёту депо зачисления приостановлены операции → отказ M2M06.", + "M2M08": "Операция нарушит закон РФ (например, бумага для квалинвесторов, а инвестор не квалифицирован) → отказ M2M08.", +} + +type fancyItem struct { + it *IncomingTransfer + code string + decision string + folder string +} + +type fancyOutItem struct { + v ClaimView + req []byte + resp []byte + dec []byte + outcome string + code string + folder string +} + +// isTestClientDoc — документ принадлежит тест-клиенту плана (К1/К2 из m2mTestClients), +// а не робот-заявке (2001/111111, 3333/100000 и т.п.). +func isTestClientDoc(series, number string) bool { + for _, cl := range m2mTestClients { + if cl.DocSeries == series && cl.DocNumber == number { + return true + } + } + return false +} + +// writeFancyBundle собирает ZIP-эталон обмена с контрагентом (обе стороны). +func writeFancyBundle(w http.ResponseWriter, r *http.Request, h *setupHandlers) { + ctx := r.Context() + m2mMu.Lock() + plan := m2mLoadPlan() + m2mMu.Unlock() + cp := strings.ToUpper(strings.TrimSpace(plan.CounterpartyCode)) + + // --- входящие (контрагент → мы) --- + var in []fancyItem + if globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + if it.Status != "answered" { + continue + } + if cp != "" && strings.ToUpper(it.SenderCode) != cp { + continue + } + code, dec := fancyDecision(it) + in = append(in, fancyItem{it: it, code: code, decision: dec}) + } + } + sort.Slice(in, func(i, j int) bool { return in[i].it.ReceivedAt.Before(in[j].it.ReceivedAt) }) + for i := range in { + tag := "priem" + if in[i].code != "" { + tag = "otkaz-" + in[i].code + } + in[i].folder = fmt.Sprintf("vhodyashie--kontragent-nam/%02d-%s/", i+1, tag) + } + + // --- исходящие (мы → контрагент), только реальные тест-клиенты --- + var out []fancyOutItem + if page, err := h.svc.ListClaims(ctx, m2mcore.Filter{Limit: 5000}); err == nil { + for _, v := range page.Items { + if !isTestClientDoc(v.Investor.Document.Series, v.Investor.Document.Number) { + continue + } + oi := fancyOutItem{v: v, outcome: nsdOutcomeSummary(v), code: claimRejectCode(v)} + oi.req, _ = h.svc.ClaimRawXML(ctx, v.ID, "request") + oi.resp, _ = h.svc.ClaimRawXML(ctx, v.ID, "response") + oi.dec, _ = h.svc.ClaimRawXML(ctx, v.ID, "decision") + out = append(out, oi) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].v.CreatedAt.Before(out[j].v.CreatedAt) }) + for i := range out { + tag := "prinyato" + if out[i].v.Status == "rejected" { + tag = "otkaz" + if out[i].code != "" { + tag += "-" + out[i].code + } + } + out[i].folder = fmt.Sprintf("ishodyashie--my-kontragentu/%02d-%s/", i+1, tag) + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + if f, e := zw.Create("00-kak-rabotaet-process.html"); e == nil { + _, _ = f.Write(fancyProcessHTML(processExample{})) // офлайн: файлы шагов — в папках ниже + } + if f, e := zw.Create("opisanie-dlya-fancy.html"); e == nil { + _, _ = f.Write(fancyBundleDocHTML(plan, in, out)) + } + // входящие + for _, fi := range in { + if fi.it.RawXML != "" { + writeZipFile(zw, fi.folder+"request-ot-kontragenta.xml", faithfulXMLBytes(fi.it.RawXML)) + } + if fi.it.DecisionXML != "" { + writeZipFile(zw, fi.folder+"nash-otvet-decision.xml", faithfulXMLBytes(fi.it.DecisionXML)) + } + writeZipFile(zw, fi.folder+"opisanie.txt", []byte(fancyItemNote(fi))) + } + // исходящие + for _, oi := range out { + if len(oi.req) > 0 { + writeZipFile(zw, oi.folder+"nash-request.xml", oi.req) + } + if len(oi.resp) > 0 { + writeZipFile(zw, oi.folder+"otvet-servisa-response.xml", oi.resp) + } + if len(oi.dec) > 0 { + writeZipFile(zw, oi.folder+"otvet-kontragenta-decision.xml", oi.dec) + } + writeZipFile(zw, oi.folder+"opisanie.txt", []byte(fancyOutNote(oi))) + } + if err := zw.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fname := "fancy-etalon-obmena-" + time.Now().Format("2006-01-02") + ".zip" + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`) + _, _ = w.Write(buf.Bytes()) +} + +func writeZipFile(zw *zip.Writer, name string, data []byte) { + if f, e := zw.Create(name); e == nil { + _, _ = f.Write(data) + } +} + +// claimRejectCode — первый код отказа по заявке (из решения контрагента или ответа сервиса). +func claimRejectCode(v ClaimView) string { + if v.M2MDecision != nil { + for _, s := range v.M2MDecision.Securities { + if len(s.RejectCodes) > 0 { + return s.RejectCodes[0] + } + } + } + if v.M2MResponse != nil { + for _, e := range v.M2MResponse.Responses { + if e.Code != "" { + return e.Code + } + } + } + return "" +} + +// fancyDecision — код и человекочитаемое решение по входящему (по бумагам). +func fancyDecision(it *IncomingTransfer) (code, text string) { + conf, rej := 0, 0 + var codes []string + for _, s := range it.Securities { + switch s.Decision { + case "confirmed": + conf++ + case "rejected": + rej++ + if s.RejectCode != "" && !contains(codes, s.RejectCode) { + codes = append(codes, s.RejectCode) + } + } + } + switch { + case rej == 0 && conf > 0: + return "", "Принято" + case conf == 0 && rej > 0: + return codes[0], "Отказано (" + strings.Join(codes, ", ") + ")" + case conf > 0 && rej > 0: + return codes[0], fmt.Sprintf("Частично: принято %d, отказано %d (%s)", conf, rej, strings.Join(codes, ", ")) + } + return "", "Отвечено" +} + +func contains(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +func fancyItemNote(fi fancyItem) string { + it := fi.it + var b strings.Builder + b.WriteString("=== ВХОДЯЩИЙ (контрагент → мы). Fancy должна дать такое же решение ===\n\n") + fmt.Fprintf(&b, "От контрагента: %s\n", it.SenderCode) + fmt.Fprintf(&b, "GUID: %s\n", it.GUID) + fmt.Fprintf(&b, "Получен: %s МСК\n", mskFmt(it.ReceivedAt)) + fmt.Fprintf(&b, "Инвестор: %s %s %s, паспорт %s № %s\n", it.InvestorLast, it.InvestorFirst, it.InvestorMiddle, it.DocSeries, it.DocNumber) + b.WriteString("Бумаги:\n") + for _, s := range it.Securities { + key := s.ISIN + if key == "" { + key = s.SecurityCode + } + fmt.Fprintf(&b, " - %s × %s → %s %s\n", key, s.Quantity, ruDecision(s.Decision), s.RejectCode) + } + fmt.Fprintf(&b, "\nНАШЕ РЕШЕНИЕ: %s\n", fi.decision) + fmt.Fprintf(&b, "ПРАВИЛО ДЛЯ FANCY: %s\n", fancyRule[fi.code]) + b.WriteString("\nФайлы: request-ot-kontragenta.xml — что прислал контрагент; nash-otvet-decision.xml — что мы ответили (эталон).\n") + return b.String() +} + +func fancyOutNote(oi fancyOutItem) string { + v := oi.v + var b strings.Builder + b.WriteString("=== ИСХОДЯЩИЙ (мы → контрагент). Fancy должна сформировать такой же запрос ===\n\n") + fmt.Fprintf(&b, "GUID: %s\n", v.M2MGUID) + fmt.Fprintf(&b, "Создан: %s МСК\n", mskFmt(v.CreatedAt)) + fmt.Fprintf(&b, "Инвестор: %s, паспорт %s № %s\n", fio(v.Investor), v.Investor.Document.Series, v.Investor.Document.Number) + b.WriteString("Бумаги:\n") + for _, s := range v.Securities { + q := s.Quantity.Fractional + if q == "" { + q = fmt.Sprintf("%d", s.Quantity.Whole) + } + fmt.Fprintf(&b, " - %s × %s\n", s.SecurityCode, q) + } + fmt.Fprintf(&b, "\nОТВЕТ КОНТРАГЕНТА/СЕРВИСА: %s (статус заявки: %s)\n", oi.outcome, russianState(v.Status)) + if oi.code != "" { + fmt.Fprintf(&b, "Код: %s — %s\n", oi.code, m2mCodeText(oi.code)) + } + b.WriteString("\nФайлы: nash-request.xml — что мы отправили; otvet-servisa-response.xml / otvet-kontragenta-decision.xml — что нам ответили.\n") + return b.String() +} + +func mskFmt(t time.Time) string { + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") +} + +func ruDecision(d string) string { + switch d { + case "confirmed": + return "ПРИНЯТО" + case "rejected": + return "ОТКАЗ" + } + return "—" +} + +// fancyBundleDocHTML — сопроводительное описание для программиста Fancy (обе стороны). +func fancyBundleDocHTML(plan m2mPlan, in []fancyItem, out []fancyOutItem) []byte { + esc := html.EscapeString + var b strings.Builder + b.WriteString(`Эталон обмена для Fancy`) + b.WriteString(`
    ` + moexMostLogoSVG + orgLogoIMG() + `
    `) + b.WriteString(`

    Эталон тестового обмена M2M для интеграции с Fancy

    `) + + cpName := plan.CounterpartyName + if cpName == "" { + cpName = plan.CounterpartyCode + } + b.WriteString(`

    Полная запись тестового обмена с контрагентом ` + esc(cpName) + ` (код ` + esc(plan.CounterpartyCode) + `), обе стороны. Задача: настроить Fancy так, чтобы по такому же входящему она давала такое же решение, а исходящие формировала так же, как здесь.

    `) + b.WriteString(fmt.Sprintf(`

    В архиве: %d входящих (папка vhodyashie--kontragent-nam/) и %d исходящих (папка ishodyashie--my-kontragentu/), только по реальным тест-клиентам (Бубликов, Баранкин) — робот-заявки исключены. В каждой папке дословные XML и opisanie.txt.

    `, len(in), len(out))) + + // Входящие + b.WriteString(`

    1. Входящие (контрагент → мы) — какое решение должна дать Fancy

    `) + b.WriteString(``) + for i, fi := range in { + it := fi.it + var secs []string + for _, s := range it.Securities { + key := s.ISIN + if key == "" { + key = s.SecurityCode + } + secs = append(secs, esc(key)+" × "+esc(s.Quantity)) + } + dcls := "ok" + if fi.code != "" { + dcls = "no" + } + fioIn := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle) + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, i+1, esc(shortFolder(fi.folder)))) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + } + b.WriteString(`
    № / папкаВремяИнвесторБумага × кол-воНаше решениеПравило для Fancy
    %02d
    %s
    ` + esc(it.ReceivedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04")) + `` + esc(fioIn) + `
    пасп. ` + esc(it.DocSeries) + ` № ` + esc(it.DocNumber) + `
    ` + strings.Join(secs, "
    ") + `
    ` + esc(fi.decision) + `` + esc(fancyRule[fi.code]) + `
    `) + + // Исходящие + b.WriteString(`

    2. Исходящие (мы → контрагент) — что мы отправляли и что нам ответили

    `) + b.WriteString(``) + for i, oi := range out { + v := oi.v + var secs []string + for _, s := range v.Securities { + secs = append(secs, esc(s.SecurityCode)) + } + ocls := "ok" + if v.Status == "rejected" { + ocls = "no" + } + res := esc(oi.outcome) + if oi.code != "" { + res += `
    ` + esc(m2mCodeText(oi.code)) + `` + } + b.WriteString(``) + b.WriteString(fmt.Sprintf(``, i+1, esc(shortFolder(oi.folder)))) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + } + b.WriteString(`
    № / папкаВремяИнвесторБумагаОтвет контрагента
    %02d
    %s
    ` + esc(v.CreatedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04")) + `` + esc(fio(v.Investor)) + `
    пасп. ` + esc(v.Investor.Document.Series) + ` № ` + esc(v.Investor.Document.Number) + `
    ` + strings.Join(secs, "
    ") + `
    ` + res + `
    `) + + b.WriteString(`

    Коды отказа — что проверяет Fancy

    `) + for _, c := range []string{"M2M01", "M2M02", "M2M03", "M2M04", "M2M05", "M2M06", "M2M08"} { + rule := fancyRule[c] + if rule == "" { + rule = m2mCodeText(c) + } + b.WriteString(``) + } + b.WriteString(`
    КодЗначениеПравило
    ` + c + `` + esc(m2mCodeText(c)) + `` + esc(rule) + `
    `) + b.WriteString(`

    Все XML — дословные (windows-1251), как прошли через НРД. Отобраны только реальные тест-клиенты; служебные робот-прогоны в пакет не включены.

    `) + b.WriteString(``) + return []byte(b.String()) +} + +// fancyProcessHTML — описание всего процесса M2M «как это работает у нас»: +// что даёт НРД, как мы обрабатываем, как отправляем обратно через шлюз ИШ. +// Отдельный акцент на депозитарных поручениях 16 (списание) и 16/1 (зачисление). +func fancyProcessHTML(ex processExample) []byte { + var b strings.Builder + b.WriteString(`Как работает процесс M2M (16 / 16.1)`) + b.WriteString(`
    ` + moexMostLogoSVG + orgLogoIMG() + `
    `) + b.WriteString(`

    Как работает процесс перевода ценных бумаг (M2M)

    `) + b.WriteString(`

    Наглядная схема процесса для программиста: как мы формируем и отправляем, что присылает НРД (файлы 16 / 16.1), как мы их обрабатываем и кладём в шлюз, и как итог — бумаги списываются и зачисляются. Ниже — обе стороны и пошаговые детали.

    `) + + chip := func(cls, title, sub string) string { + s := "" + if sub != "" { + s = `` + sub + `` + } + return `
    ` + title + s + `
    ` + } + arr := `→` + + b.WriteString(`

    Схема: исходящий перевод (мы отправляем, бумаги СПИСЫВАЮТСЯ)

    `) + b.WriteString(`
    ` + + chip("c-us", "1. Мы сформировали заявку", "M2MTransferRequest") + arr + + chip("c-nsd", "2. Ушло в НРД", "через шлюз ИШ") + arr + + chip("c-cp", "3. Контрагент ответил", "согласие") + arr + + chip("c-nsd", "4. НРД прислал файл 16", "черновик поручения (списание)") + arr + + chip("c-us", "5. Мы обработали → шлюз", "подали поручение ORDXM") + arr + + chip("c-done", "6. ✓ Бумаги списаны", "отчёт MS010") + + `
    `) + + b.WriteString(`

    Схема: входящий перевод (нам присылают, бумаги ЗАЧИСЛЯЮТСЯ)

    `) + b.WriteString(`
    ` + + chip("c-cp", "1. Контрагент отправил", "M2MTransferRequest") + arr + + chip("c-nsd", "2. НРД прислал нам", "запрос в INBOX") + arr + + chip("c-us", "3. Мы ответили", "решение: принять/отказать") + arr + + chip("c-nsd", "4. НРД прислал файл 16.1", "черновик поручения (зачисление)") + arr + + chip("c-us", "5. Мы обработали → шлюз", "подали поручение ORDXM") + arr + + chip("c-done", "6. ✓ Бумаги зачислены", "отчёт MS010") + + `
    `) + + // Компоненты + b.WriteString(`

    Участники и компоненты

    `) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
    КтоРоль
    НРД / МОСТСервис быстрых переводов ценных бумаг MOEX МОСТ. Проводит сделку, формирует черновики поручений, выдаёт отчёты.
    ИШ (шлюз)Интеграционный шлюз НРД (igate). Приём/отправка пакетов, ГОСТ-подпись. Папки обмена: INBOX (пришло от НРД), OUTBOX/SENT/ERRORS. REST API: POST /api/package/{канал}/file, GET /api/package?channel=&type=, GET /api/package/{id}.
    bj (мы)Наша система. Читает INBOX, принимает решения, упаковывает и шлёт документы через ИШ, отслеживает ответы.
    КонтрагентДепозитарий на другой стороне перевода (в тестах — Банк ЗЕНИТ).
    FancyУчётная (депозитарная) система. Должна принимать решения по входящим и формировать исходящие так же, как мы делали руками.
    `) + + // Типы ЭД + b.WriteString(`

    Типы электронных документов (ЭД)

    `) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(`
    ДокументТип / пакетКто шлёт → комуСмысл
    M2MTransferRequest#M2MTRинициатор → НРД → контрагентЗапрос на перевод ценных бумаг
    M2MTransferResponse#M2MERНРД → инициаторОтвет сервиса МОСТ (коды M2Mxx, ошибки/информирование)
    M2MTransferDecision#M2MTDзачисляющий → НРД → списывающийРешение по бумагам: принять / отказать (с кодом)
    Черновик поручения (MF010)#M2MDOНРД → обе стороныЧерновик формы 16 (списание) или 16/1 (зачисление). Отличаются полем <order_t_id>
    Поручение депоORDXMдепозитарий → НРДПоданный черновик 16 / 16/1 как классическое поручение (config.xml <package>K</package>)
    Уведомление о принятииREORDНРД → депозитарийНРД принял поручение депо
    Отчёт по поручению депоXMLRP = MS010НРД → депозитарийОперация проведена (внутри zip: repxml.xml с MS010/sese.025 + dbf)
    `) + + // Входящий процесс (16/1) + b.WriteString(`

    Процесс А. Входящий перевод — мы ЗАЧИСЛЯЮЩИЙ (поручение 16/1)

    `) + b.WriteString(`

    Контрагент списывает бумаги и переводит их нам. Мы решаем — принять или отказать, затем подаём встречное поручение 16/1 (зачисление).

    `) + render := func(items []pstep) { + for i, it := range items { + var dl string + if it.href != "" { + dl = `` + } + did := "" + if it.did != "" { + did = `
    Что делаем: ` + it.did + `
    ` + } + sys := "" + if it.system != "" { + sys = `
    Куда вносится: ` + it.system + `
    ` + } + b.WriteString(`
    ` + fmt.Sprintf("%d", i+1) + `
    ` + it.who + it.text + dl + did + sys + `
    `) + } + } + hIf := func(cond bool, href string) string { + if cond { + return href + } + return "" + } + inURL := func(seg string) string { return "/admin/incoming/" + ex.InID + "/" + seg } + clURL := func(seg string) string { return "/admin/claims/" + ex.OutClaimID + "/" + seg } + outURL := func(seg string) string { return "/admin/outgoing/" + ex.OutGUID + "/" + seg } + + render([]pstep{ + {who: `НРД`, text: `Кладёт нам в INBOX пакет #M2MTR — M2MTransferRequest (инвестор, документ, какие бумаги нам переводят).`, + href: hIf(ex.InID != "" && ex.InHasReq, inURL("request.xml")), dlabel: "Запрос контрагента (request.xml)", + did: `разбираем, показываем во «Входящих», сверяем данные по каждой бумаге.`, system: `реестр bj; сверка данных — Fancy (депозитарный учёт).`}, + {who: `bj → ИШ`, text: `Формируем M2MTransferDecision — по каждой бумаге принять/отказать (+ код отказа), шлём пакетом #M2MTD (GUID ответа = GUID запроса).`, + href: hIf(ex.InID != "" && ex.InHasDec, inURL("decision.xml")), dlabel: "Наше решение (decision.xml)", + did: `сверяем данные запроса с картотекой (см. раздел «Что сверяем» ниже) и решаем.`, system: `уходит в НРД через шлюз ИШ.`}, + {who: `НРД`, text: `Если приняли — кладёт в INBOX черновик поручения 16/1 (..._MF010.xml, <order_t_id>16/1</order_t_id>, <based_on>=GUID, <contr_d_id>=№ поручения).`, + href: hIf(ex.InID != "" && ex.InHasDraft, inURL("draft.zip")), dlabel: "Черновик 16/1 от НРД (draft.zip)", + did: `связываем по based_on=GUID, готовим к подаче.`, system: `Fancy (депоучёт) — формирование/подача поручения.`}, + {who: `bj → ИШ`, text: `Подаём черновик 16/1 в НРД как поручение депо ORDXM: XML как есть (windows-1251) в zip с <package>K</package>, POST /api/package/{канал}/file. ИШ подписывает ГОСТ.`, + href: hIf(ex.InID != "" && ex.InHasOrder, inURL("order.xml")), dlabel: "Наше поручение 16/1 (order.xml)", + did: `упаковали и отправили поручение депо в НРД.`, system: `НРД через шлюз ИШ.`}, + {who: `НРД`, text: `Присылает квитанцию XCONF → принятие REORD → сматчив встречное поручение 16 контрагента, проводит зачисление и присылает отчёт MS010 (XMLRP).`, + href: hIf(ex.InID != "" && ex.InHasMS010, inURL("ms010.zip")), dlabel: "Ответ НРД MS010 (ms010.zip)", + did: `находим по contr_d_id, статус «Проведено · MS010» — бумаги зачислены.`, system: `провести зачисление в Fancy; блокировки бумаг — QUIK МНП.`}, + }) + + // Исходящий процесс (16) + b.WriteString(`

    Процесс Б. Исходящий перевод — мы СПИСЫВАЮЩИЙ (поручение 16)

    `) + b.WriteString(`

    Мы списываем бумаги инвестора и переводим их контрагенту. Зеркально процессу А, но черновик приходит формы 16 (списание).

    `) + outRespHref := "" + if ex.OutClaimID != "" { + if ex.OutHasDec { + outRespHref = clURL("decision.xml") + } else if ex.OutHasResp { + outRespHref = clURL("response.xml") + } + } + render([]pstep{ + {who: `bj → ИШ`, text: `Формируем M2MTransferRequest (инвестор, бумаги, счёт списания) и шлём пакетом #M2MTR.`, + href: hIf(ex.OutClaimID != "" && ex.OutHasReq, clURL("request.xml")), dlabel: "Наш запрос (request.xml)", + did: `собираем запрос по заявке клиента.`, system: `запрос из Fancy (заявка клиента) → НРД через ИШ.`}, + {who: `НРД / Контрагент`, text: `Приходит #M2MER (M2MTransferResponse, коды сервиса) и M2MTransferDecision контрагента — согласие/отказ.`, + href: outRespHref, dlabel: "Ответ (decision/response.xml)", + did: `разбираем ответ; при согласии ждём черновик 16.`, system: `результат — в Fancy (статус заявки).`}, + {who: `НРД`, text: `После согласия кладёт в INBOX черновик поручения 16 (..._MF010.xml, <order_t_id>16</order_t_id> — списание).`, + href: hIf(ex.OutGUID != "" && ex.OutHasDraft, outURL("draft.zip")), dlabel: "Черновик 16 от НРД (draft.zip)", + did: `связываем по GUID, готовим к подаче.`, system: `Fancy (депоучёт) — подача поручения.`}, + {who: `bj → ИШ`, text: `Подаём черновик 16 в НРД как поручение депо ORDXM (тот же механизм: zip + <package>K</package>, ИШ подписывает).`, + href: hIf(ex.OutGUID != "" && ex.OutHasOrder, outURL("order.xml")), dlabel: "Наше поручение 16 (order.xml)", + did: `упаковали и отправили поручение депо.`, system: `НРД через шлюз ИШ.`}, + {who: `НРД`, text: `Матчит наш 16 со встречным 16/1 контрагента, проводит списание и присылает отчёт MS010 — бумага ушла контрагенту.`, + href: hIf(ex.OutGUID != "" && ex.OutHasMS010, outURL("ms010.zip")), dlabel: "Ответ НРД MS010 (ms010.zip)", + did: `статус «Списание проведено · MS010».`, system: `провести списание в Fancy; блокировки — QUIK МНП.`}, + }) + + // Что Fancy сверяет по данным + b.WriteString(`

    Что Fancy сверяет по данным (почему принимаем / почему отказываем)

    `) + b.WriteString(`

    По входящему запросу Fancy сверяет данные из запроса со своей картотекой. Если всё совпало и ограничений нет — принимаем. Если что-то не совпало — отказываем с соответствующим кодом.

    `) + b.WriteString(``) + chk := func(field, against, ok, code string) { + b.WriteString(``) + } + chk("Ценная бумага (ISIN / код НРД)", "справочник обслуживаемых выпусков", "выпуск обслуживаем", "не обслуживаем → M2M05") + chk("Допустимость к M2M (ISIN)", "правила сервиса МОСТ (только RU-ISIN)", "российский выпуск, допустим", "иностранный/недопустим → M2M01") + chk("Инвестор (ФИО + документ ДУЛ: тип, серия, номер)", "картотека клиентов", "клиент найден", "клиента нет → M2M04") + chk("Счёт депо зачисления", "счета клиента", "счёт определён", "счёт не определён → M2M02") + chk("Тип счёта депо", "признак «торговый»", "счёт торговый", "счёт неторговый → M2M03") + chk("Статус счёта депо", "блокировки/приостановки", "операции разрешены", "приостановлены → M2M06") + chk("Правовые ограничения по бумаге", "квалификация инвестора / закон РФ", "ограничений нет", "нарушение (напр. квалинвестор) → M2M08") + b.WriteString(`
    Данные из запросаС чем сверяем в Fancy✓ Совпало → принимаем✗ Не совпало → отказ
    ` + field + `` + against + `` + ok + `` + code + `
    `) + b.WriteString(`

    Если по всем строкам «совпало» — бумага принимается (Confirmation). Если хотя бы одна проверка не прошла — бумага отклоняется с первым сработавшим кодом. Для исходящих Fancy формирует запрос из этих же данных клиента.

    `) + + // Как отправляем через шлюз + b.WriteString(`

    Как именно отправляем через шлюз ИШ

    `) + b.WriteString(`

    Упаковка

    Документ (XML в windows-1251) кладётся в ZIP вместе с config.xml, где указан тип пакета. Для M2M-документов тип начинается с # (например #M2MTD). Для поручения депо — <package>K</package> (тип ORDXM).

    `) + b.WriteString(`

    Отправка

    POST /api/package/{канал}/file (multipart, поле File = zip, Type=ARCHIVE) → ИШ возвращает id пакета. Подпись ГОСТ ставит сам ИШ — нам подписывать не нужно. Статус: GET /api/package/status/{id} (NEW → SENT → ERROR).

    `) + b.WriteString(`

    Приём

    Входящие ИШ складывает в INBOX отдельными пакетами-папками. Мы читаем их (наш поллер), а также тянем через GET /api/package?channel=&type= и GET /api/package/{id}.

    `) + + b.WriteString(`
    Ключевое про 16 и 16/1. Оба черновика приходят одним типом файла ..._MF010.xml; отличаются только полем <order_t_id>: 16 — списание (наш исходящий), 16/1 — зачисление (наш входящий). Связь с M2M-сделкой — по <based_on> (GUID). Номер поручения — <contr_d_id>: по нему мы потом находим отчёт MS010. Подаются они одинаково — типом ЭД ORDXM.
    `) + + b.WriteString(`

    Дословные примеры файлов каждого шага — в этом же архиве (папки vhodyashie / ishodyashie).

    `) + b.WriteString(``) + return []byte(b.String()) +} + +// pstep — шаг процесса с файлом, описанием действия и целевой системой. +type pstep struct{ who, text, href, dlabel, did, system string } + +// processExample — конкретный завершённый пример перевода (по одному на сторону) +// для скачивания файлов каждого шага на странице процесса. +type processExample struct { + InID string + InHasReq, InHasDec, InHasDraft, InHasOrder, InHasMS010 bool + OutClaimID, OutGUID string + OutHasReq, OutHasResp, OutHasDec, OutHasDraft, OutHasOrder bool + OutHasMS010 bool +} + +// buildProcessExample подбирает наиболее полный пример входящего и исходящего +// перевода и выясняет, какие файлы по нему доступны для скачивания. +func buildProcessExample(r *http.Request, h *setupHandlers) processExample { + ctx := r.Context() + var ex processExample + // Входящий: предпочитаем тот, где подано поручение (полная цепочка до MS010). + if globalIncomingStore != nil { + var best *IncomingTransfer + for _, it := range globalIncomingStore.List() { + if it.Status != "answered" { + continue + } + if it.OrderSubmitted { + best = it + break + } + if best == nil { + best = it + } + } + if best != nil { + ex.InID = best.ID + ex.InHasReq = best.RawXML != "" + ex.InHasDec = best.DecisionXML != "" + ex.InHasDraft = len(findMF010Files(best.GUID)) > 0 + ex.InHasOrder = best.OrderXML != "" + ex.InHasMS010 = len(collectOrderResponseRaw(best.OrderContrDID)) > 0 + } + } + // Исходящий: реальный тест-клиент, предпочитаем с черновиком 16 / поданным поручением. + if page, err := h.svc.ListClaims(ctx, m2mcore.Filter{Limit: 5000}); err == nil { + var best ClaimView + bestScore := -1 + for _, v := range page.Items { + if !isTestClientDoc(v.Investor.Document.Series, v.Investor.Document.Number) { + continue + } + score := 0 + g := string(v.M2MGUID) + if globalOutgoingStore != nil { + if _, ok := scanForm16Deal(globalOutgoingStore, g); ok { + score += 3 + } + if sub, ok := globalOutgoingStore.Get(g); ok && sub.OrderXML != "" { + score += 3 + } + } + if v.Status != "" { + score++ + } + if score > bestScore { + best = v + bestScore = score + } + } + if bestScore >= 0 { + ex.OutClaimID = best.ID + ex.OutGUID = string(best.M2MGUID) + req, _ := h.svc.ClaimRawXML(ctx, best.ID, "request") + ex.OutHasReq = len(req) > 0 + resp, _ := h.svc.ClaimRawXML(ctx, best.ID, "response") + ex.OutHasResp = len(resp) > 0 + dec, _ := h.svc.ClaimRawXML(ctx, best.ID, "decision") + ex.OutHasDec = len(dec) > 0 + if globalOutgoingStore != nil { + if _, ok := scanForm16Deal(globalOutgoingStore, ex.OutGUID); ok { + ex.OutHasDraft = true + } + if sub, ok := globalOutgoingStore.Get(ex.OutGUID); ok { + ex.OutHasOrder = sub.OrderXML != "" + ex.OutHasMS010 = len(collectOutgoingResponseRaw(sub.ContrDIDs)) > 0 + } + } + } + } + return ex +} + +func shortFolder(f string) string { + f = strings.TrimSuffix(f, "/") + if i := strings.LastIndex(f, "/"); i >= 0 { + return f[i+1:] + } + return f +} diff --git a/internal/lkgateway/fancydb.go b/internal/lkgateway/fancydb.go new file mode 100644 index 0000000..71a5fdb --- /dev/null +++ b/internal/lkgateway/fancydb.go @@ -0,0 +1,130 @@ +package lkgateway + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strings" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" // драйвер "pgx" для PostgreSQL + _ "github.com/microsoft/go-mssqldb" // драйвер "sqlserver" для MSSQL +) + +// Подключение к серверу учётной базы клиента Fancy DEPO — MSSQL или PostgreSQL, +// SQL-логин. База выбирается в настройках (DepoDB). + +// validDBName — безопасное имя БД/идентификатора MSSQL (буквы/цифры/_), чтобы +// не дать инъекции в CREATE DATABASE (имя БД нельзя параметризовать). +func validDBName(s string) bool { + if s == "" || len(s) > 64 { + return false + } + for i, c := range s { + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || (i > 0 && c >= '0' && c <= '9') + if !ok { + return false + } + } + return true +} + +// fancyDriver возвращает имя database/sql-драйвера по типу СУБД. +func fancyDriver(s FancyMSSQLSettings) string { + if s.IsPostgres() { + return "pgx" + } + return "sqlserver" +} + +// fancyAdminDB — имя служебной БД для создания целевой: master (MSSQL) / +// postgres (PostgreSQL). Подключаемся к ней, чтобы выполнить CREATE DATABASE. +func fancyAdminDB(s FancyMSSQLSettings) string { + if s.IsPostgres() { + return "postgres" + } + return "master" +} + +// fancyDSN строит строку подключения. db="" → к служебной БД (для CREATE DATABASE). +func fancyDSN(s FancyMSSQLSettings, db string) string { + port := s.DefaultPort() + if s.IsPostgres() { + ssl := s.Encrypt + switch ssl { + case "", "disable", "false": + ssl = "disable" + case "true": // в UI значение из MSSQL-набора — мапим в требование TLS + ssl = "require" + } + q := url.Values{} + q.Set("sslmode", ssl) + q.Set("connect_timeout", "10") + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(s.User, s.Password), + Host: fmt.Sprintf("%s:%d", s.Server, port), + Path: "/" + db, + RawQuery: q.Encode(), + } + return u.String() + } + // MSSQL + enc := s.Encrypt + if enc == "" { + enc = "disable" // по умолчанию без TLS (типовой LAN-сценарий) + } + q := url.Values{} + if db != "" { + q.Set("database", db) + } + q.Set("encrypt", enc) + q.Set("connection timeout", "10") + u := &url.URL{ + Scheme: "sqlserver", + User: url.UserPassword(s.User, s.Password), + Host: fmt.Sprintf("%s:%d", s.Server, port), + RawQuery: q.Encode(), + } + return u.String() +} + +// fancyOpen открывает пул подключений к указанной БД (или служебной при db=""). +func fancyOpen(s FancyMSSQLSettings, db string) (*sql.DB, error) { + if s.IsPostgres() && db == "" { + db = fancyAdminDB(s) + } + conn, err := sql.Open(fancyDriver(s), fancyDSN(s, db)) + if err != nil { + return nil, err + } + conn.SetConnMaxLifetime(5 * time.Minute) + conn.SetMaxOpenConns(4) + return conn, nil +} + +// fancyPing проверяет соединение и возвращает версию SQL Server. +func fancyPing(ctx context.Context, s FancyMSSQLSettings, db string) (string, error) { + conn, err := fancyOpen(s, db) + if err != nil { + return "", err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + if err := conn.PingContext(cctx); err != nil { + return "", err + } + var ver string + verQuery := "SELECT @@VERSION" + if s.IsPostgres() { + verQuery = "SELECT version()" + } + _ = conn.QueryRowContext(cctx, verQuery).Scan(&ver) + ver = strings.TrimSpace(ver) + if i := strings.IndexAny(ver, "\r\n"); i > 0 { + ver = ver[:i] + } + return ver, nil +} diff --git a/internal/lkgateway/fancydb_handlers.go b/internal/lkgateway/fancydb_handlers.go new file mode 100644 index 0000000..0a94b51 --- /dev/null +++ b/internal/lkgateway/fancydb_handlers.go @@ -0,0 +1,321 @@ +package lkgateway + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// Хендлеры вкладки «Обмен Fancy (MSSQL)»: подключение, тест, создание БД, +// наливка схемы. Маршруты вешаются в registerSetup (см. registerFancy). + +func (h *setupHandlers) registerFancy(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/fancy", h.saveFancy) + mux.HandleFunc("/admin/setup/fancy/test", h.testFancy) + mux.HandleFunc("/admin/setup/fancy/depo-job", h.saveFancyDepoJob) + mux.HandleFunc("/admin/setup/fancy/depo-job/check", h.checkFancyDepoJob) + mux.HandleFunc("/admin/setup/fancy/depo-job/create", h.createFancyDepoJob) + mux.HandleFunc("/admin/setup/fancy/depo-tables", h.saveFancyDepoTables) + mux.HandleFunc("/admin/setup/fancy/depo-tables/create", h.createFancyDepoTables) + mux.HandleFunc("/admin/setup/fancy/depo-tables/check", h.checkFancyDepoTables) + mux.HandleFunc("/admin/setup/fancy/depo-tables/script.sql", h.fancyDepoTablesScript) + mux.HandleFunc("/admin/fancy-queue", h.fancyQueue) + mux.HandleFunc("/admin/fancy-queue/requeue", h.fancyQueueRequeue) +} + +// fancyFromForm собирает настройки из формы (пароль пустой = оставить прежний). +func (h *setupHandlers) fancyFromForm(r *http.Request) FancyMSSQLSettings { + cur := h.rc.Snapshot().Fancy + dbType := strings.TrimSpace(r.FormValue("db_type")) + if dbType != "postgres" { + dbType = "mssql" + } + port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) + if port == 0 { + if dbType == "postgres" { + port = 5432 + } else { + port = 1433 + } + } + pass := r.FormValue("password") + if pass == "" { + pass = cur.Password // не затираем сохранённый пароль пустым полем + } + enc := strings.TrimSpace(r.FormValue("encrypt")) + if enc == "" { + enc = "disable" + } + // остальные настройки (обработка DEPO, трансферные таблицы) сохраняем как были + s := cur + s.DBType, s.Port, s.Password, s.Encrypt = dbType, port, pass, enc + s.Server = strings.TrimSpace(r.FormValue("server")) + s.User = strings.TrimSpace(r.FormValue("user")) + if s.Server != cur.Server || s.DBType != cur.DBType || s.User != cur.User { + s.DepoTablesCheck, s.DepoTablesCheckOK = "", false + s.DepoJobCheck, s.DepoJobCheckOK = "", false + } + return s +} + +// saveFancyDepoTables — POST /admin/setup/fancy/depo-tables: база DEPO и способ создания таблиц. +func (h *setupHandlers) saveFancyDepoTables(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Fancy + db := strings.TrimSpace(r.FormValue("depo_database")) + if db != "" && !validDBName(db) { + setupFlash(w, r, "Таблицы DEPO: имя базы — только латинские буквы/цифры/_, начинать с буквы") + return + } + if m := strings.TrimSpace(r.FormValue("iis_contract_marker")); len([]rune(m)) <= 100 { + s.IISContractMarker = m + } + mode := r.FormValue("depo_tables_mode") + if mode != "auto" && mode != "script" { + mode = "" + } + if db != s.DepoDatabase { + s.DepoTablesCheck, s.DepoTablesCheckOK = "", false + } + s.DepoDatabase, s.DepoTablesMode = db, mode + if err := h.rc.UpdateFancy(s); err != nil { + setupFlash(w, r, "Таблицы DEPO: ошибка сохранения: "+err.Error()) + return + } + switch mode { + case "auto": + setupFlash(w, r, "Таблицы DEPO: bj создаст их в базе "+s.DepoDB()+" — введите логин с правами и нажмите «Создать таблицы».") + case "script": + setupFlash(w, r, "Таблицы DEPO: передайте скрипт администратору СУБД, после выполнения нажмите «Проверить таблицы».") + default: + setupFlash(w, r, "Таблицы DEPO: база "+s.DepoDB()+" сохранена, способ создания не выбран") + } +} + +func (h *setupHandlers) saveDepoTablesCheck(msg string, ok bool) { + s := h.rc.Snapshot().Fancy + s.DepoTablesCheck, s.DepoTablesCheckOK, s.DepoTablesCheckAt = msg, ok, time.Now() + _ = h.rc.UpdateFancy(s) +} + +// createFancyDepoTables — POST /admin/setup/fancy/depo-tables/create: bj создаёт таблицы сам. +// Логин с правами берётся из формы и нигде не сохраняется. +func (h *setupHandlers) createFancyDepoTables(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Fancy + if s.Server == "" { + setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + msg, err := depoTablesCreate(ctx, s, r.FormValue("admin_user"), r.FormValue("admin_password")) + if err != nil { + log.Printf("depo-tables: создание не удалось: %v", err) + setupFlash(w, r, "Таблицы DEPO: не созданы — "+err.Error()) + return + } + log.Printf("depo-tables: %s", msg) + check, ok := depoTablesCheck(ctx, s) + h.saveDepoTablesCheck(check, ok) + if ok && !s.IsPostgres() { + if err := depoWriteHandbook(h.rc); err == nil { + msg += "; справочник участников записан" + } else { + log.Printf("depo-tables: справочник: %v", err) + } + } + setupFlash(w, r, "Таблицы DEPO: "+msg+". Проверка: "+check) +} + +// checkFancyDepoTables — POST /admin/setup/fancy/depo-tables/check. +func (h *setupHandlers) checkFancyDepoTables(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Fancy + if s.Server == "" { + setupFlash(w, r, "Таблицы DEPO: сначала сохраните подключение к серверу БД") + return + } + ctx, cancel := context.WithTimeout(r.Context(), time.Minute) + defer cancel() + msg, ok := depoTablesCheck(ctx, s) + h.saveDepoTablesCheck(msg, ok) + if ok { + setupFlash(w, r, "Таблицы DEPO: готово — "+msg) + } else { + setupFlash(w, r, "Таблицы DEPO: НЕ готово — "+msg) + } +} + +// fancyDepoTablesScript — GET /admin/setup/fancy/depo-tables/script.sql: скрипт для администратора СУБД. +func (h *setupHandlers) fancyDepoTablesScript(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot().Fancy + body := depoTablesScript(s) + if !s.IsPostgres() { + body = "\uFEFF" + body // SSMS без BOM читает кириллицу в комментариях как ANSI + } + w.Header().Set("Content-Type", "application/sql; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="`+depoTablesFileName(s)+`"`) + _, _ = w.Write([]byte(body)) +} + +// saveFancyDepoJob — POST /admin/setup/fancy/depo-job: обработка трансферных таблиц силами bj. +func (h *setupHandlers) saveFancyDepoJob(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Fancy + mode := r.FormValue("depo_job_mode") + if mode != "bj" && mode != "sql-auto" && mode != "sql-manual" { + mode = "" + } + if s.DepoJobMode != mode { + s.DepoJobCheck, s.DepoJobCheckOK = "", false + } + s.DepoJobMode = mode + s.DepoJobRun = mode == "bj" + if v, err := strconv.Atoi(strings.TrimSpace(r.FormValue("depo_job_every_sec"))); err == nil && v >= 5 && v <= 3600 { + s.DepoJobEverySec = v + } + s.DepoJobProc = strings.TrimSpace(r.FormValue("depo_job_proc")) + if u := strings.TrimSpace(r.FormValue("depo_job_run_as")); u == "" || depoRunAsRe.MatchString(u) { + if u != s.DepoJobRunAs { + s.DepoJobCheck, s.DepoJobCheckOK = "", false + } + s.DepoJobRunAs = u + } + if err := h.rc.UpdateFancy(s); err != nil { + setupFlash(w, r, "Обработка DEPO: ошибка сохранения: "+err.Error()) + return + } + switch mode { + case "bj": + setupFlash(w, r, fmt.Sprintf("Обработка DEPO: bj сам вызывает %s каждые %d с. Нажмите «Проверить готовность».", depoJobProcName(s), depoJobEvery(s))) + case "sql-auto": + setupFlash(w, r, "Обработка DEPO: джоба SQL Agent, создаёт bj. Выдайте права по инструкции и нажмите «Создать джобу».") + case "sql-manual": + setupFlash(w, r, "Обработка DEPO: джоба SQL Agent, настраивает администратор. Передайте инструкцию и нажмите «Проверить готовность».") + default: + setupFlash(w, r, "Обработка DEPO: способ не выбран") + } +} + +// checkFancyDepoJob — POST /admin/setup/fancy/depo-job/check: проверка готовности выбранного способа. +func (h *setupHandlers) checkFancyDepoJob(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second) + defer cancel() + msg, ok := depoJobCheck(ctx, h.rc) + s := h.rc.Snapshot().Fancy + s.DepoJobCheck, s.DepoJobCheckOK, s.DepoJobCheckAt = msg, ok, time.Now() + _ = h.rc.UpdateFancy(s) + if ok { + setupFlash(w, r, "Обработка DEPO: готово — "+msg) + } else { + setupFlash(w, r, "Обработка DEPO: НЕ готово — "+msg) + } +} + +// createFancyDepoJob — POST /admin/setup/fancy/depo-job/create: создать джобу SQL Agent от имени bj. +func (h *setupHandlers) createFancyDepoJob(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + if err := depoJobCreateAgent(ctx, h.rc); err != nil { + setupFlash(w, r, "Создание джобы: "+err.Error()) + return + } + msg, ok := depoJobCheck(ctx, h.rc) + s := h.rc.Snapshot().Fancy + s.DepoJobCheck, s.DepoJobCheckOK, s.DepoJobCheckAt = msg, ok, time.Now() + _ = h.rc.UpdateFancy(s) + setupFlash(w, r, "Джоба SQL Agent создана. Проверка: "+msg) +} + +// saveFancy — POST /admin/setup/fancy. Сохраняет параметры подключения. +func (h *setupHandlers) saveFancy(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.fancyFromForm(r) + dbLabel := "MSSQL" + if s.IsPostgres() { + dbLabel = "PostgreSQL" + } + if s.Server == "" || s.User == "" { + setupFlash(w, r, dbLabel+": укажите сервер и пользователя") + return + } + if err := h.rc.UpdateFancy(s); err != nil { + setupFlash(w, r, dbLabel+": ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, fmt.Sprintf("%s: настройки сохранены (%s:%d)", dbLabel, s.Server, s.Port)) +} + +// testFancy — POST /admin/setup/fancy/test. Проверяет соединение к серверу. +func (h *setupHandlers) testFancy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Fancy + if s.Server == "" { + setupFlash(w, r, "MSSQL: сначала сохраните параметры подключения") + return + } + // Проверяем соединение к серверу (master) — БД может ещё не существовать. + ver, err := fancyPing(r.Context(), s, "master") + if err != nil { + setupFlash(w, r, fancyDBLabel(s)+": соединение не удалось — "+err.Error()) + return + } + setupFlash(w, r, fmt.Sprintf("%s: соединение OK. %s", fancyDBLabel(s), strings.TrimSpace(ver))) +} + +// fancyDBLabel — «MSSQL» или «PostgreSQL» для сообщений. +func fancyDBLabel(s FancyMSSQLSettings) string { + if s.IsPostgres() { + return "PostgreSQL" + } + return "MSSQL" +} diff --git a/internal/lkgateway/fontvielle_logo.go b/internal/lkgateway/fontvielle_logo.go new file mode 100644 index 0000000..f8dd007 --- /dev/null +++ b/internal/lkgateway/fontvielle_logo.go @@ -0,0 +1,24 @@ +package lkgateway + +import "strings" + +// orgLogoIMG — логотипа для правой части шапки отчёта. Если в настройках +// загружен свой логотип (Branding.LogoDataURI) — используется он, иначе встроенный +// логотип Фонтвьель по умолчанию. +func orgLogoIMG() string { + if globalRC != nil { + if u := strings.TrimSpace(globalRC.Snapshot().Branding.LogoDataURI); u != "" { + return `Логотип организации` + } + } + return fontvielleLogoIMG +} + +// fontvielle_logo.go — логотип ООО ИК «Фонтвьель» (data-URI PNG), встраивается в +// шапку отчётов для регулятора справа (слева — логотип MOEX МОСТ/НРД). Источник: +// https://fontvielle.ru/wp-content/uploads/2022/09/logo1.png +// fontvielleLogoIMG — готовый логотипа для правой части шапки отчёта +// (margin-left:auto прижимает его к правому краю flex-контейнера .brand). +const fontvielleLogoIMG = `ООО ИК «Фонтвьель»` + +const fontvielleLogoDataURI = "data:image/png;base64,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" diff --git a/internal/lkgateway/health.go b/internal/lkgateway/health.go new file mode 100644 index 0000000..804706c --- /dev/null +++ b/internal/lkgateway/health.go @@ -0,0 +1,290 @@ +package lkgateway + +import ( + "context" + "fmt" + "log" + "net" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// health.go — стабильность и самоконтроль сервиса. +// +// Три независимых слоя защиты, чтобы сбой не остался незамеченным: +// 1. superviseLoop — оборачивает каждую фоновую горутину: recover() ловит +// панику, пишет в лог и в ленту, и перезапускает горутину. Паника одного +// поллера больше не роняет весь процесс. +// 2. heartbeat — каждый поллер «отмечается» на каждом тике. Монитор следит за +// свежестью отметок; если компонент завис (нет отметок > 3 интервалов) — +// поднимает алерт в ленту «Новости» (оператор увидит). +// 3. sd_notify watchdog — пока все компоненты живы, процесс «пинает» systemd +// (WATCHDOG=1). Если внутренний цикл завис намертво (дедлок) и отметки +// прекратились — systemd по таймауту сам перезапустит весь сервис. + +// compHealth — состояние одного наблюдаемого компонента. +type compHealth struct { + Name string + Interval time.Duration // ожидаемый интервал между отметками + LastBeat time.Time + LastErr string + alerted bool // алерт «завис» уже поднят — не дублируем +} + +var ( + healthMu sync.Mutex + healthReg = map[string]*compHealth{} +) + +// healthRegister объявляет компонент и его ожидаемый интервал отметок. +func healthRegister(name string, interval time.Duration) { + healthMu.Lock() + defer healthMu.Unlock() + if _, ok := healthReg[name]; !ok { + healthReg[name] = &compHealth{Name: name, Interval: interval, LastBeat: time.Now()} + } +} + +// healthBeat — компонент жив: обновляет отметку и снимает флаг алерта. +func healthBeat(name string) { + healthMu.Lock() + defer healthMu.Unlock() + c := healthReg[name] + if c == nil { + c = &compHealth{Name: name, Interval: time.Minute} + healthReg[name] = c + } + c.LastBeat = time.Now() + c.alerted = false +} + +// healthError фиксирует последнюю ошибку компонента (для UI; на watchdog не +// влияет — временная сетевая ошибка не должна перезапускать сервис). +func healthError(name, msg string) { + healthMu.Lock() + defer healthMu.Unlock() + if c := healthReg[name]; c != nil { + c.LastErr = msg + } +} + +// healthComponent возвращает зарегистрированный компонент по имени (или nil). +func healthComponent(name string) *compHealth { + healthMu.Lock() + defer healthMu.Unlock() + return healthReg[name] +} + +// HealthStatus — снимок состояния компонента для панели администратора. +type HealthStatus struct { + Name string + Healthy bool + Age time.Duration + LastErr string + LastBeat time.Time +} + +// HealthSnapshot возвращает состояние всех компонентов, отсортированное по имени. +// Healthy = свежая отметка (моложе 3 интервалов). LastErr на Healthy не влияет. +func HealthSnapshot() []HealthStatus { + healthMu.Lock() + defer healthMu.Unlock() + out := make([]HealthStatus, 0, len(healthReg)) + now := time.Now() + for _, c := range healthReg { + age := now.Sub(c.LastBeat) + out = append(out, HealthStatus{ + Name: c.Name, + Healthy: age < 3*c.Interval, + Age: age, + LastErr: c.LastErr, + LastBeat: c.LastBeat, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// superviseLoop запускает body в бесконечном цикле с recover(): паника или +// неожиданный выход body → лог + алерт + перезапуск через 5 с. Возвращается +// только когда ctx отменён (штатная остановка). +func superviseLoop(ctx context.Context, name string, rc *RuntimeConfig, body func(ctx context.Context)) { + for { + if ctx.Err() != nil { + return + } + crashed := runGuarded(name, rc, func() { body(ctx) }) + if ctx.Err() != nil { + return + } + if crashed { + healthError(name, "перезапуск после сбоя") + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + } +} + +// runGuarded выполняет fn под recover(); true, если была паника. +func runGuarded(name string, rc *RuntimeConfig, fn func()) (crashed bool) { + defer func() { + if r := recover(); r != nil { + crashed = true + msg := fmt.Sprintf("%v", r) + log.Printf("lk-gateway: КОМПОНЕНТ %s УПАЛ (паника): %s — перезапуск через 5с", name, msg) + healthError(name, "паника: "+msg) + raiseSystemAlert(rc, "Сбой компонента: "+name, + fmt.Sprintf("Компонент «%s» аварийно завершился (%s) и автоматически перезапускается. Проверьте лог bj-server.", name, msg)) + } + }() + fn() + return false +} + +// superviseHealth — монитор здоровья + sd_notify watchdog. Раз в 15 с проверяет +// свежесть отметок всех компонентов: зависшие → алерт в ленту; если все живы — +// «пинает» systemd (при Type=notify + WatchdogSec). Запускается напрямую (сам +// себя не роняет — тело тривиально и без паник). +func (s *Server) superviseHealth(ctx context.Context) { + sdNotify("READY=1") + wd := watchdogTimeout() + if wd > 0 { + log.Printf("lk-gateway: systemd watchdog активен (таймаут %s)", wd) + } + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + allLive := true + for _, h := range HealthSnapshot() { + // Затяжное зависание (>6 интервалов): восстановить горутину не + // удалось (recover бы уже перезапустил). Сообщаем и перезапускаем + // весь процесс — systemd (Restart=always) поднимет за 5 с. + if c := healthComponent(h.Name); c != nil && h.Age > 6*c.Interval { + log.Printf("lk-gateway: КОМПОНЕНТ %s ЗАВИС НАМЕРТВО (%s без отметок) — перезапуск процесса", h.Name, h.Age.Round(time.Second)) + raiseSystemAlertSync(s.rc, "Аварийный перезапуск сервиса", + fmt.Sprintf("Компонент «%s» завис (%s без признаков работы) и не восстановился. Сервис перезапускается автоматически.", h.Name, h.Age.Round(time.Second))) + time.Sleep(300 * time.Millisecond) + os.Exit(1) + } + if h.Healthy { + continue + } + allLive = false + s.alertStale(h) + } + // Пинаем watchdog только когда все компоненты живы (если включён + // Type=notify + WatchdogSec — дополнительный внешний контроль). + if allLive { + sdNotify("WATCHDOG=1") + } + } + } +} + +// alertStale поднимает алерт «компонент завис» единожды (до восстановления). +func (s *Server) alertStale(h HealthStatus) { + healthMu.Lock() + c := healthReg[h.Name] + if c == nil || c.alerted { + healthMu.Unlock() + return + } + c.alerted = true + healthMu.Unlock() + log.Printf("lk-gateway: КОМПОНЕНТ %s НЕ ОТВЕЧАЕТ (нет отметок %s)", h.Name, h.Age.Round(time.Second)) + raiseSystemAlert(s.rc, "Компонент не отвечает: "+h.Name, + fmt.Sprintf("Фоновый компонент «%s» не подавал признаков работы %s. Сервис пытается восстановиться автоматически; если сообщение повторяется — проверьте bj-server.", h.Name, h.Age.Round(time.Second))) +} + +// raiseSystemAlert добавляет системное событие в ленту «Новости» (дедуп по +// заголовку в пределах 10 минут, чтобы не засорять при флапе). +func raiseSystemAlert(rc *RuntimeConfig, title, body string) { + if rc == nil { + return + } + id := "sys-" + strconv.FormatInt(time.Now().Unix()/600, 10) + "-" + slugify(title) + _ = rc.AddNews(NewsItem{ + ID: id, + At: time.Now(), + Kind: "system", + Title: title, + Body: body, + }) + // Внешняя доставка (SMTP/Zabbix) — асинхронно, чтобы не блокировать монитор. + go dispatchAlert(rc, title, body) +} + +// raiseSystemAlertSync — как raiseSystemAlert, но с синхронной внешней доставкой: +// нужен на пути аварийного os.Exit, где горутины не успеют отработать. +func raiseSystemAlertSync(rc *RuntimeConfig, title, body string) { + if rc == nil { + return + } + id := "sys-" + strconv.FormatInt(time.Now().Unix()/600, 10) + "-" + slugify(title) + _ = rc.AddNews(NewsItem{ID: id, At: time.Now(), Kind: "system", Title: title, Body: body}) + dispatchAlert(rc, title, body) +} + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } else if r == ' ' || r == '-' || r == ':' { + b.WriteByte('-') + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "alert" + } + if len(out) > 40 { + out = out[:40] + } + return out +} + +// --- sd_notify (systemd) без внешних зависимостей --- + +// sdNotify шлёт сообщение состояния в systemd через NOTIFY_SOCKET. +// No-op, если сокет не задан (запуск вне systemd). +func sdNotify(state string) { + sock := os.Getenv("NOTIFY_SOCKET") + if sock == "" { + return + } + if strings.HasPrefix(sock, "@") { // абстрактный сокет + sock = "\x00" + sock[1:] + } + conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: sock, Net: "unixgram"}) + if err != nil { + return + } + defer conn.Close() + _, _ = conn.Write([]byte(state)) +} + +// watchdogTimeout возвращает интервал watchdog из WATCHDOG_USEC (0 — выключен). +func watchdogTimeout() time.Duration { + v := os.Getenv("WATCHDOG_USEC") + if v == "" { + return 0 + } + usec, err := strconv.ParseInt(v, 10, 64) + if err != nil || usec <= 0 { + return 0 + } + return time.Duration(usec) * time.Microsecond +} diff --git a/internal/lkgateway/incoming.go b/internal/lkgateway/incoming.go new file mode 100644 index 0000000..9ccc541 --- /dev/null +++ b/internal/lkgateway/incoming.go @@ -0,0 +1,347 @@ +package lkgateway + +import ( + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// Входящие переводы: M2MTransferRequest, присланные НРД/роботом, когда другая +// сторона выступает ПЕРЕДАЮЩЕЙ (сценарий 3333). На такой запрос мы должны +// ответить M2MTransferDecision — по каждой бумаге подтвердить приём или +// отказать. Здесь — хранилище входящих и их состояние ответа. + +const incomingStateFile = "/var/lib/bj/.bj/incoming-transfers.json" + +// IncomingSecurity — бумага во входящем переводе (что нам предлагают принять). +type IncomingSecurity struct { + ReferenceID string `json:"reference_id"` // Security/ReferenceId (нужен для ответа) + SecurityCode string `json:"security_code"` // код НРД ценной бумаги + ISIN string `json:"isin"` + Quantity string `json:"quantity"` // целое ИЛИ дробное (паи) — как строка + // Реквизиты счёта из запроса (место зачисления/списания). + SettleINN string `json:"settle_inn,omitempty"` // ИНН расчётного депозитария + DeponentCode string `json:"deponent_code,omitempty"` // код депонента + AccountID string `json:"account_id,omitempty"` // счёт депо + SectionID string `json:"section_id,omitempty"` // раздел счёта + // Решение по бумаге (после ответа): "confirmed" | "rejected" | "". + Decision string `json:"decision,omitempty"` + RejectCode string `json:"reject_code,omitempty"` +} + +// IncomingTransfer — входящий перевод от робота/контрагента, ожидающий нашего +// решения (принять/отказать по бумагам). +type IncomingTransfer struct { + ID string `json:"id"` // наш внутренний id + GUID string `json:"guid"` // GUID запроса (в ответе используем ЕГО) + RequestCreated time.Time `json:"request_created,omitempty"` // когда отправитель сформировал запрос + ReceivedAt time.Time `json:"received_at"` // когда мы забрали из шлюза + SenderCode string `json:"sender_code"` // кто прислал (робот MC0012500000) + ReceiverCode string `json:"receiver_code"` // мы + SenderINN string `json:"sender_inn"` // TransferringDepository/INN (передающий) + // Анкета инвестора и документ из запроса. + InvestorLast string `json:"investor_last,omitempty"` + InvestorFirst string `json:"investor_first,omitempty"` + InvestorMiddle string `json:"investor_middle,omitempty"` + DocType string `json:"doc_type,omitempty"` + DocSeries string `json:"doc_series,omitempty"` + DocNumber string `json:"doc_number,omitempty"` + ReceivingINN string `json:"receiving_inn,omitempty"` // принимающий депозитарий (мы) + Securities []IncomingSecurity `json:"securities"` + RawXML string `json:"raw_xml"` + DecisionXML string `json:"decision_xml,omitempty"` // наш ответ M2MTransferDecision (для выгрузки регулятору) + Status string `json:"status"` // pending | answered + AnsweredAt time.Time `json:"answered_at,omitempty"` + AnswerNote string `json:"answer_note,omitempty"` + Auto bool `json:"auto,omitempty"` // отвечено автоматически (дубль) + Fingerprint string `json:"fingerprint,omitempty"` // отпечаток для детекта дублей + // Подача депозитарного поручения 16/1 (ORDXM) в НРД. + OrderSubmitted bool `json:"order_submitted,omitempty"` + OrderContrDID string `json:"order_contr_did,omitempty"` // contr_d_id поручения (связь с ответами НРД) + OrderPkgs string `json:"order_pkgs,omitempty"` // id пакетов ИШ + OrderAt time.Time `json:"order_at,omitempty"` + OrderXML string `json:"order_xml,omitempty"` // что мы реально отправили (поручение 16/1) + // Сверка с DEPO (эмуляция ответа, ничего не отправляется) — для бейджа в списке. + DepoRecommend string `json:"depo_recommend,omitempty"` // confirm | reject | mixed + DepoSummary string `json:"depo_summary,omitempty"` // «принять» / «отказ M2M04» / … + DepoAt time.Time `json:"depo_at,omitempty"` +} + +// IncomingStore — потокобезопасное хранилище входящих переводов с персистом +// в JSON (переживает перезапуск bj-server). +type IncomingStore struct { + mu sync.Mutex + items map[string]*IncomingTransfer +} + +// NewIncomingStore создаёт хранилище и загружает сохранённые входящие. +func NewIncomingStore() *IncomingStore { + s := &IncomingStore{items: make(map[string]*IncomingTransfer)} + s.load() + return s +} + +func (s *IncomingStore) load() { + b, err := os.ReadFile(incomingStateFile) + if err != nil { + return + } + var list []*IncomingTransfer + if json.Unmarshal(b, &list) != nil { + return + } + for _, it := range list { + s.items[it.ID] = it + } +} + +func (s *IncomingStore) save() { + list := make([]*IncomingTransfer, 0, len(s.items)) + for _, it := range s.items { + list = append(list, it) + } + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return + } + tmp := incomingStateFile + ".tmp" + if os.WriteFile(tmp, b, 0o640) == nil { + _ = os.Rename(tmp, incomingStateFile) + } +} + +// AddFromRequest сохраняет входящий перевод из распарсенного M2MTransferRequest. +// guidExists=true если перевод с таким GUID уже сохранён (дедуп). +func (s *IncomingStore) AddFromRequest(req *m2m.M2MTransferRequest, rawXML string) (*IncomingTransfer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + guid := string(req.Header.GUID) + for _, it := range s.items { + if it.GUID == guid { + return it, true // уже есть + } + } + id, _ := newID() + inv := req.Data.InvestorInformation + it := &IncomingTransfer{ + ID: id, + GUID: guid, + RequestCreated: req.Header.CreationTimestamp.Time, + ReceivedAt: time.Now().UTC(), + SenderCode: string(req.Header.SenderCode), + ReceiverCode: string(req.Header.ReceiverCode), + SenderINN: string(req.Data.TransferringDepository.INN), + ReceivingINN: string(req.Data.ReceivingDepository.INN), + InvestorLast: inv.LastName, + InvestorFirst: inv.FirstName, + InvestorMiddle: inv.MiddleName, + DocType: string(inv.IdentityDocument.DocumentType), + DocNumber: string(inv.IdentityDocument.DocumentNumber), + RawXML: rawXML, + Status: "pending", + } + if inv.IdentityDocument.DocumentSeries != nil { + it.DocSeries = string(*inv.IdentityDocument.DocumentSeries) + } + for _, sec := range req.Data.TransferredSecurities.Securities { + is := IncomingSecurity{ + ReferenceID: string(sec.ReferenceID), + SecurityCode: string(sec.SecurityCode), + } + if sec.SecurityDetails.ISIN != nil { + is.ISIN = string(*sec.SecurityDetails.ISIN) + } + if sec.Quantity.Whole != nil { + is.Quantity = strconv.FormatUint(*sec.Quantity.Whole, 10) + } else if sec.Quantity.Fractional != nil { + is.Quantity = string(*sec.Quantity.Fractional) + } + if len(sec.SettlementAccount) > 0 { + sa := sec.SettlementAccount[0] + is.SettleINN = string(sa.SettlementRequisites.INN) + is.DeponentCode = sa.SettlementLocation.DeponentCode + is.AccountID = string(sa.SettlementLocation.AccountID) + is.SectionID = sa.SettlementLocation.SectionID + } + it.Securities = append(it.Securities, is) + } + it.Fingerprint = incomingFingerprint(it) + s.items[id] = it + s.save() + return it, false +} + +// incomingFingerprint — отпечаток «такого же запроса»: отправитель + документ +// инвестора + набор бумаг (ISIN/код : количество). GUID НЕ входит (у повторного +// запроса он другой). Используется для авто-ответа на дубли. +func incomingFingerprint(it *IncomingTransfer) string { + parts := []string{it.SenderCode, it.DocType, it.DocSeries, it.DocNumber, + it.InvestorLast, it.InvestorFirst, it.InvestorMiddle} + secs := make([]string, 0, len(it.Securities)) + for _, s := range it.Securities { + key := s.ISIN + if key == "" { + key = s.SecurityCode + } + secs = append(secs, key+":"+s.Quantity) + } + sort.Strings(secs) + parts = append(parts, secs...) + return strings.Join(parts, "|") +} + +// FindAnsweredDuplicate ищет ранее ОТВЕЧЕННЫЙ входящий с тем же отпечатком +// (кроме самого it). Возвращает его решения по бумагам для повтора. +func (s *IncomingStore) FindAnsweredDuplicate(fp, exceptID string) (*IncomingTransfer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + for _, it := range s.items { + if it.ID == exceptID || it.Status != "answered" || it.Fingerprint == "" { + continue + } + if it.Fingerprint == fp { + cp := *it + return &cp, true + } + } + return nil, false +} + +// List возвращает входящие, новые сверху. +func (s *IncomingStore) List() []*IncomingTransfer { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*IncomingTransfer, 0, len(s.items)) + for _, it := range s.items { + cp := *it + out = append(out, &cp) + } + // новые сверху + for i := 0; i < len(out); i++ { + for j := i + 1; j < len(out); j++ { + if out[j].ReceivedAt.After(out[i].ReceivedAt) { + out[i], out[j] = out[j], out[i] + } + } + } + return out +} + +// Get возвращает копию входящего по id. +func (s *IncomingStore) Get(id string) (*IncomingTransfer, bool) { + s.mu.Lock() + defer s.mu.Unlock() + it, ok := s.items[id] + if !ok { + return nil, false + } + cp := *it + cp.Securities = append([]IncomingSecurity(nil), it.Securities...) + return &cp, true +} + +// PendingCount — сколько входящих ждут нашего решения (для бейджа в меню). +func (s *IncomingStore) PendingCount() int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, it := range s.items { + if it.Status == "pending" { + n++ + } + } + return n +} + +// MarkAnswered проставляет решения по бумагам и статус answered. +func (s *IncomingStore) MarkAnswered(id string, decisions map[string]IncomingSecurity, note string) { + s.mu.Lock() + defer s.mu.Unlock() + it, ok := s.items[id] + if !ok { + return + } + for i := range it.Securities { + if d, ok := decisions[it.Securities[i].ReferenceID]; ok { + it.Securities[i].Decision = d.Decision + it.Securities[i].RejectCode = d.RejectCode + } + } + it.Status = "answered" + it.AnsweredAt = time.Now().UTC() + it.AnswerNote = note + s.save() +} + +// MarkAuto помечает входящий как отвеченный автоматически (дубль) и обновляет +// примечание. +func (s *IncomingStore) MarkAuto(id, note string) { + s.mu.Lock() + defer s.mu.Unlock() + it, ok := s.items[id] + if !ok { + return + } + it.Auto = true + if note != "" { + it.AnswerNote = note + } + s.save() +} + +// SetOrderSubmitted помечает, что подано поручение депо 16/1 (ORDXM) в НРД, и +// сохраняет отправленный XML (что именно мы отправили). +func (s *IncomingStore) SetOrderSubmitted(id, contrDID, pkgs, orderXML string) { + s.mu.Lock() + defer s.mu.Unlock() + if it, ok := s.items[id]; ok { + it.OrderSubmitted = true + it.OrderContrDID = contrDID + it.OrderPkgs = pkgs + it.OrderXML = orderXML + it.OrderAt = time.Now().UTC() + s.save() + } +} + +// SetDecisionXML сохраняет XML нашего ответа-решения (для выгрузки регулятору). +func (s *IncomingStore) SetDecisionXML(id, xml string) { + s.mu.Lock() + defer s.mu.Unlock() + if it, ok := s.items[id]; ok { + it.DecisionXML = xml + s.save() + } +} + +// SetDepo сохраняет итог сверки с DEPO (рекомендация + краткий текст) — для +// бейджа в списке входящих. Ответ при этом НЕ отправляется. +func (s *IncomingStore) SetDepo(id, recommend, summary string) { + s.mu.Lock() + defer s.mu.Unlock() + if it, ok := s.items[id]; ok { + it.DepoRecommend = recommend + it.DepoSummary = summary + it.DepoAt = time.Now().UTC() + s.save() + } +} + +// newID — короткий идентификатор для входящего перевода (первые 12 симв. UUIDv4). +func newID() (string, error) { + u, err := m2mcore.NewUUIDv4() + if err != nil { + return "", err + } + if len(u) >= 12 { + return u[:12], nil + } + return u, nil +} diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go new file mode 100644 index 0000000..c60bf0b --- /dev/null +++ b/internal/lkgateway/incoming_answer.go @@ -0,0 +1,153 @@ +package lkgateway + +import ( + "context" + "fmt" + "log" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// SecurityDecision — наше решение по одной бумаге входящего перевода. +type SecurityDecision struct { + ReferenceID string + Confirm bool // true — принять, false — отказать + RejectCode string // код отказа (по умолчанию M2M05), если Confirm=false +} + +// incomingDeps — зависимости для формирования и отправки решения. +type incomingDeps struct { + store *IncomingStore + igwClient *igw.Client + igwChan string + rc *RuntimeConfig + ourCode string // наш депкод (DefaultSender) +} + +// answerIncoming формирует M2MTransferDecision на входящий перевод и отправляет +// его обратно через ИШ. GUID решения = GUID исходного запроса робота (так +// требует протокол: связь ответа с запросом). Реквизиты зачисления для +// подтверждённых бумаг берутся из настроек НРД (наш депо-счёт). +func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions []SecurityDecision) error { + it, ok := deps.store.Get(id) + if !ok { + return fmt.Errorf("входящий перевод %s не найден", id) + } + if deps.igwClient == nil || deps.igwChan == "" { + return fmt.Errorf("ИШ не настроен — нельзя отправить решение") + } + + cfg := deps.rc.Snapshot() + ourCode := deps.ourCode // наш депкод (отправитель решения) + if ourCode == "" { + ourCode = it.ReceiverCode // на кого пришёл запрос — тем и отвечаем + } + nsd := cfg.NSD + + // Карта решений по ReferenceID. + byRef := make(map[string]SecurityDecision, len(decisions)) + for _, d := range decisions { + byRef[d.ReferenceID] = d + } + + // Собираем Decision по каждой бумаге входящего перевода. + var decSecs []m2m.DecisionSecurity + markMap := make(map[string]IncomingSecurity, len(it.Securities)) + confirmed, rejected := 0, 0 + for _, sec := range it.Securities { + d, has := byRef[sec.ReferenceID] + if !has { + // По умолчанию — отказ (безопаснее не принимать без явного решения). + d = SecurityDecision{ReferenceID: sec.ReferenceID, Confirm: false} + } + ds := m2m.DecisionSecurity{ReferenceID: m2m.ReferenceID(sec.ReferenceID)} + mark := IncomingSecurity{ReferenceID: sec.ReferenceID} + if d.Confirm { + ds.TransferDecision = m2m.DecisionTransfer{ + Confirmation: &m2m.Confirmation{ + SettlementAccount: m2m.DecisionSettlementAccount{ + SettlementRequisites: m2m.SettlementRequisites{ + // ИНН МЕСТА РАСЧЁТОВ (НРД), а не наш собственный — иначе + // M2M35 «место расчётов отсутствует в реквизитах». + INN: m2m.OrganizationINN(settlementPlaceINN("nrd")), + }, + SettlementLocation: m2m.SettlementDepositoryLocation{ + DeponentCode: nsd.DeponentCode, + AccountID: m2m.AccountID(nsd.AccountID), + SectionID: nsd.SectionID, + }, + }, + }, + } + mark.Decision = "confirmed" + confirmed++ + } else { + code := d.RejectCode + if code == "" { + code = "M2M05" // дефолтный код отказа + } + ds.TransferDecision = m2m.DecisionTransfer{ + Rejection: &m2m.Rejection{Codes: []string{code}}, + } + mark.Decision = "rejected" + mark.RejectCode = code + rejected++ + } + decSecs = append(decSecs, ds) + markMap[sec.ReferenceID] = mark + } + + dec := &m2m.M2MTransferDecision{ + Header: m2m.DecisionHeader{ + GUID: m2m.UUID(it.GUID), // GUID из запроса робота! + CreationTimestamp: nsdxml.Now(), + SenderCode: m2m.DeponentCode(ourCode), + ReceiverCode: m2m.DeponentCode(it.SenderCode), // обратно роботу + CostInfo: m2m.CostInfo{No: &m2m.CostInfoNo{}}, + }, + Data: m2m.DecisionData{ + ReceivingDepository: m2m.SettlementRequisites{INN: m2m.OrganizationINN(transferringINN(cfg))}, + Securities: decSecs, + }, + } + if err := dec.Validate(); err != nil { + return fmt.Errorf("собранное решение невалидно: %w", err) + } + + xmlBytes, err := nsdxml.Marshal(dec) + if err != nil { + return fmt.Errorf("сериализация решения: %w", err) + } + deps.store.SetDecisionXML(id, string(nsdxml.DecodeWindows1251(xmlBytes))) + zipBody, err := igw.PackXML(xmlBytes, "M2MTransferDecision.xml", "#M2MTD") + if err != nil { + return fmt.Errorf("упаковка решения: %w", err) + } + sctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + pkgID, err := deps.igwClient.SendPackage(sctx, deps.igwChan, "#M2MTD", zipBody) + if err != nil { + return fmt.Errorf("отправка решения в ИШ: %w", err) + } + + note := fmt.Sprintf("Решение отправлено (пакет ИШ %s): принято %d, отказано %d", pkgID, confirmed, rejected) + deps.store.MarkAnswered(id, markMap, note) + go depoOnIncomingDecision(deps.rc, it, decisions) + log.Printf("lk-gateway: ответ на входящий перевод %s (GUID %s) отправлен: %s", id, it.GUID, note) + // Письмо депозитарию: перевод принят/зачислён (если что-то приняли). + if confirmed > 0 { + if upd, ok := deps.store.Get(id); ok { + go notifyDepositoryAccepted(deps.rc, upd, confirmed, rejected) + } + } + return nil +} + +// transferringINN — ИНН нашей организации (принимающий депозитарий в решении). +// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000). +func transferringINN(_ Settings) string { + return "7703807489" +} diff --git a/internal/lkgateway/incoming_draft.go b/internal/lkgateway/incoming_draft.go new file mode 100644 index 0000000..7b43fff --- /dev/null +++ b/internal/lkgateway/incoming_draft.go @@ -0,0 +1,81 @@ +package lkgateway + +// incoming_draft.go — скачивание черновика поручения 16/1 (MF010), который НРД +// сформировал по нашему согласию на прием. Черновик передаётся в депозитарную +// учётную систему (Fancy-DEPO), чтобы та подала поручение 16/1 в НРД (перевод +// бумаг между счетами номинального держателя) — тогда НРД проведёт зачисление и +// выпустит отчёт MS010. Здесь мы просто отдаём файл(ы) черновика на скачивание. + +import ( + "archive/zip" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +// findMF010Files ищет все файлы черновика MF010 (xml + подпись .SGN), +// сформированные НРД по данному переводу (по GUID в based_on). +func findMF010Files(guid string) []string { + guidLower := strings.ToLower(strings.TrimSpace(guid)) + var xmls, dirs []string + seen := map[string]bool{} + _ = filepath.Walk(igwInboxDir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !strings.HasSuffix(strings.ToUpper(p), "_MF010.XML") { + return nil + } + b, e := os.ReadFile(p) + if e != nil || !strings.Contains(strings.ToLower(string(b)), guidLower) { + return nil + } + xmls = append(xmls, p) + if d := filepath.Dir(p); !seen[d] { + seen[d] = true + dirs = append(dirs, d) + } + return nil + }) + // добавляем .SGN рядом с каждым xml + out := append([]string{}, xmls...) + for _, x := range xmls { + sgn := strings.TrimSuffix(x, filepath.Ext(x)) + ".SGN" + if _, e := os.Stat(sgn); e == nil { + out = append(out, sgn) + } + } + return out +} + +const igwInboxDir = "/var/lib/igate/exchange/INBOX" + +// downloadIncomingDraft отдаёт ZIP с черновиком(ами) поручения 16/1 (MF010) по +// переводу — для загрузки в депозитарную учётную систему. +func downloadIncomingDraft(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) { + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "входящий перевод не найден", http.StatusNotFound) + return + } + files := findMF010Files(it.GUID) + if len(files) == 0 { + http.Error(w, "черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="poruchenie-16-1-%s.zip"`, shortID(it.ID))) + zw := zip.NewWriter(w) + defer zw.Close() + for _, p := range files { + b, e := os.ReadFile(p) + if e != nil { + continue + } + if f, e := zw.Create(filepath.Base(p)); e == nil { + _, _ = f.Write(b) + } + } +} diff --git a/internal/lkgateway/incoming_export.go b/internal/lkgateway/incoming_export.go new file mode 100644 index 0000000..4332219 --- /dev/null +++ b/internal/lkgateway/incoming_export.go @@ -0,0 +1,231 @@ +package lkgateway + +// incoming_export.go — выгрузка входящего перевода для контролирующих органов +// (аналог выгрузки по исходящим заявкам): ZIP с отчётом-доказательством, исходным +// XML запроса контрагента и XML нашего ответа-решения. Фиксирует весь путь: +// когда запрос сформирован, когда мы забрали его из шлюза, когда ответили. + +import ( + "archive/zip" + "fmt" + "html" + "net/http" + "strings" + "time" +) + +// exportIncoming отдаёт ZIP-пакет доказательств по одному входящему переводу. +func exportIncoming(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) { + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "входящий перевод не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="vhodyaschiy-%s.zip"`, shortID(it.ID))) + zw := zip.NewWriter(w) + defer zw.Close() + + if f, e := zw.Create("otchet.html"); e == nil { + _, _ = f.Write(incomingReportHTML(it)) + } + if it.RawXML != "" { + if f, e := zw.Create("request.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.RawXML)) + } + } + if it.DecisionXML != "" { + if f, e := zw.Create("decision.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.DecisionXML)) + } + } + if it.OrderXML != "" { + if f, e := zw.Create("poruchenie-16-1.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.OrderXML)) + } + } + // Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX. + for _, rf := range collectOrderResponseRaw(it.OrderContrDID) { + if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil { + _, _ = f.Write(rf.Data) + } + } +} + +// incomingReportHTML формирует HTML-отчёт-доказательство по входящему переводу. +func incomingReportHTML(it *IncomingTransfer) []byte { + esc := html.EscapeString + msk := func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + " МСК" + } + dur := func(a, b time.Time) string { + if a.IsZero() || b.IsZero() { + return "—" + } + d := b.Sub(a) + if d < 0 { + d = -d + } + return d.Round(time.Second).String() + } + senderName := handbookNameByCode(it.SenderCode) + fio := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle) + + var b strings.Builder + b.WriteString(``) + b.WriteString(`Отчёт о входящем переводе M2M`) + + b.WriteString(`
    ` + moexMostLogoSVG + + `Сервис быстрых переводов ценных бумаг
    MOEX МОСТ · НКО АО НРД
    ` + orgLogoIMG() + `
    `) + b.WriteString(`

    Отчёт о входящем переводе ценных бумаг (M2M / MOEX МОСТ)

    `) + b.WriteString(`
    Доказательство для контролирующих органов. Внутренний id bj: ` + esc(it.ID) + + ` · Сформировано: ` + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + ` МСК
    `) + + // Реквизиты + b.WriteString(`

    Реквизиты перевода

    `) + row := func(k, v string) { b.WriteString(``) } + row("GUID перевода", ``+esc(it.GUID)+``) + from := esc(it.SenderCode) + if it.SenderINN != "" { + from += ` · ИНН ` + esc(it.SenderINN) + } + if senderName != "" { + from = `` + esc(senderName) + ` · ` + from + } + row("Передающий депозитарий (списывающий)", from) + to := esc(it.ReceiverCode) + if it.ReceivingINN != "" { + to += ` · ИНН ` + esc(it.ReceivingINN) + } + row("Принимающий депозитарий (мы)", to) + row("Инвестор (ФИО)", ``+esc(fio)+``) + doc := esc(it.DocType) + if it.DocSeries != "" { + doc += ` · серия ` + esc(it.DocSeries) + } + if it.DocNumber != "" { + doc += ` · № ` + esc(it.DocNumber) + } + row("Документ инвестора", doc) + b.WriteString(`
    ` + k + `` + v + `
    `) + + // Хронология (фиксация времени прохождения) + b.WriteString(`

    Хронология прохождения сообщения

    `) + row("Запрос сформирован отправителем", msk(it.RequestCreated)) + row("Получено нами (забрано из шлюза ИШ)", msk(it.ReceivedAt)+` (время доставки: `+dur(it.RequestCreated, it.ReceivedAt)+`)`) + if it.Status == "answered" { + row("Ответ (решение) отправлен", msk(it.AnsweredAt)+` (время обработки: `+dur(it.ReceivedAt, it.AnsweredAt)+`)`) + } else { + row("Ответ (решение) отправлен", `— (ещё не отвечено)`) + } + status := "ждёт решения" + if it.Status == "answered" { + status = "отвечено" + if it.Auto { + status += " (автоматически, после проверки в Fancy)" + } + } + row("Статус", esc(status)) + if it.AnswerNote != "" { + row("Результат", esc(it.AnswerNote)) + } + b.WriteString(`
    `) + + // Бумаги + b.WriteString(`

    Ценные бумаги

    `) + b.WriteString(``) + for _, s := range it.Securities { + code := esc(s.SecurityCode) + if s.ISIN != "" { + code += ` / ` + esc(s.ISIN) + } + acc := "" + if s.DeponentCode != "" { + acc += `депонент ` + esc(s.DeponentCode) + } + if s.AccountID != "" { + acc += `
    счёт ` + esc(s.AccountID) + } + if s.SectionID != "" { + acc += ` / раздел ` + esc(s.SectionID) + } + if s.SettleINN != "" { + acc += `
    расч.деп. ИНН ` + esc(s.SettleINN) + } + dec := "—" + switch s.Decision { + case "confirmed": + dec = "принято (зачислено)" + case "rejected": + dec = "отказ · " + esc(m2mCodeLabel(s.RejectCode)) + } + b.WriteString(``) + } + b.WriteString(`
    Код НРД / ISINКол-воСчёт зачисленияРешение
    ` + code + `` + esc(s.Quantity) + `` + acc + `` + dec + `
    `) + + // Депозитарное исполнение: поручение 16/1 (ORDXM) + ответы НРД (движение). + if it.OrderSubmitted { + resps := findOrderResponses(it.OrderContrDID) + var ms010At string + for _, rp := range resps { + if rp.Done { + ms010At = rp.Time + } + } + b.WriteString(`

    Депозитарное исполнение (поручение 16/1) — хронология

    `) + if dr := draftReceivedTime(it.GUID); dr != "" { + row("Черновик 16/1 получен от НРД (MF010)", esc(dr)+` МСК`) + } + row("Поручение 16/1 подано в НРД", msk(it.OrderAt)) + if ms010At != "" { + row("Отчёт MS010 получен (операция проведена)", esc(ms010At)+` МСК — бумаги зачислены`) + } else { + row("Отчёт MS010 получен", `— (ещё не получен)`) + } + row("Тип ЭД / пакет(ы) ИШ", "ORDXM · "+esc(it.OrderPkgs)) + if it.OrderContrDID != "" { + row("Номер поручения (contr_d_id)", esc(it.OrderContrDID)) + } + b.WriteString(`
    `) + if len(resps) > 0 { + b.WriteString(`

    Ответы НРД по поручению

    `) + b.WriteString(``) + for _, rp := range resps { + b.WriteString(``) + } + b.WriteString(`
    Время полученияТип ответаПакет
    ` + esc(rp.Time) + ` МСК` + esc(rp.Kind) + `` + esc(rp.Pkg) + `
    `) + } + if it.OrderXML != "" { + b.WriteString(`

    Отправленное поручение 16/1 (дословно)

    ` + esc(it.OrderXML) + `
    `) + } + } + + // Все поля запроса (разбор XML) + if fields := renderXMLFieldsTable(it.RawXML); fields != "" { + b.WriteString(`

    Все поля запроса (разбор XML)

    ` + fields) + } + + // Приложения — сырые XML (также отдельными файлами в архиве) + if it.RawXML != "" { + b.WriteString(`

    Исходный XML запроса контрагента (request.xml)

    ` + esc(it.RawXML) + `
    `) + } + if it.DecisionXML != "" { + b.WriteString(`

    XML нашего ответа-решения (decision.xml)

    ` + esc(it.DecisionXML) + `
    `) + } + + b.WriteString(`

    Отчёт сформирован автоматически системой Bridge-and-Join-s (bj). Логотип MOEX МОСТ — собственность НКО АО НРД.

    `) + b.WriteString(``) + return []byte(b.String()) +} diff --git a/internal/lkgateway/incoming_handlers.go b/internal/lkgateway/incoming_handlers.go new file mode 100644 index 0000000..e1b3ddf --- /dev/null +++ b/internal/lkgateway/incoming_handlers.go @@ -0,0 +1,236 @@ +package lkgateway + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +// registerIncoming вешает маршруты «Входящие переводы» (сценарий 3333): +// список, карточка с выбором по бумагам, отправка решения роботу. +func registerIncoming(mux *http.ServeMux, a *admin, deps incomingDeps) { + mux.HandleFunc("/admin/incoming", func(w http.ResponseWriter, r *http.Request) { + data := struct { + page + Items []*IncomingTransfer + }{nowPage("Входящие переводы", "incoming"), deps.store.List()} + render(w, a.incoming, data) + }) + + // JSON-фид для браузерных уведомлений: список входящих, ждущих решения. + // Фронтенд (layout.html) опрашивает его и показывает Notification на новые. + mux.HandleFunc("/admin/incoming/feed", func(w http.ResponseWriter, r *http.Request) { + type feedItem struct { + ID string `json:"id"` + GUID string `json:"guid"` + Sender string `json:"sender"` + Count int `json:"count"` + Received string `json:"received"` + } + var pending []feedItem + for _, it := range deps.store.List() { + if it.Status != "pending" { + continue + } + pending = append(pending, feedItem{ + ID: it.ID, GUID: it.GUID, Sender: it.SenderCode, + Count: len(it.Securities), Received: it.ReceivedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05"), + }) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]any{"count": len(pending), "pending": pending}) + }) + + // Тестовый прогон входящего в DEPO с реальным клиентом (без отправки в НРД). + mux.HandleFunc("/admin/incoming/depo-sim", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + id := r.FormValue("id") + it, ok := deps.store.Get(id) + if !ok { + http.NotFound(w, r) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + guid, err := depoSimulateIncoming(ctx, deps.rc, it, r.FormValue("ser"), r.FormValue("num")) + if err != nil { + http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ("Прогон в DEPO: "+err.Error()), http.StatusSeeOther) + return + } + // ждём, создаст ли джоба документ + doc := "" + if db, e := depoEventsDB(deps.rc); e == nil { + for i := 0; i < 8; i++ { + time.Sleep(5 * time.Second) + var d sql.NullString + _ = db.QueryRowContext(ctx, "SELECT CAST(DOC AS varchar(32)) FROM dbo.FTV_M2M WHERE GUID=@g", sql.Named("g", guid)).Scan(&d) + if d.Valid && d.String != "" && d.String != "0" { + doc = d.String + break + } + } + db.Close() + } + msg := "Прогон в DEPO записан: новый GUID " + guid + ", клиент по паспорту " + r.FormValue("ser") + " " + r.FormValue("num") + if doc != "" { + msg += ", джоба создала документ DEPO №" + doc + } else { + msg += "; документ DEPO пока не создан (джоба не ответила за 40 с)" + } + http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+urlQ(msg), http.StatusSeeOther) + }) + mux.HandleFunc("/admin/incoming/", func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/admin/incoming/") + // POST /admin/incoming/{id}/answer + if strings.HasSuffix(p, "/answer") { + id := path.Base(strings.TrimSuffix(p, "/answer")) + handleIncomingAnswer(w, r, deps, id) + return + } + // GET /admin/incoming/{id}/export.zip — выгрузка для регулятора + if strings.HasSuffix(p, "/export.zip") { + id := path.Base(strings.TrimSuffix(p, "/export.zip")) + exportIncoming(w, r, deps, id) + return + } + // GET /admin/incoming/{id}/draft.zip — черновик поручения 16/1 (MF010) для учётной системы + if strings.HasSuffix(p, "/draft.zip") { + id := path.Base(strings.TrimSuffix(p, "/draft.zip")) + downloadIncomingDraft(w, r, deps, id) + return + } + // POST /admin/incoming/{id}/submit-order — подать поручение 16/1 (ORDXM) в НРД + if strings.HasSuffix(p, "/submit-order") { + id := path.Base(strings.TrimSuffix(p, "/submit-order")) + submitDepoOrder(w, r, deps, id) + return + } + // GET /admin/incoming/{id}/order.xml — скачать отправленную форму 16/1 + if strings.HasSuffix(p, "/order.xml") { + id := path.Base(strings.TrimSuffix(p, "/order.xml")) + downloadSentOrder(w, r, deps, id) + return + } + // GET /admin/incoming/{id}/request.xml — исходный запрос контрагента + if strings.HasSuffix(p, "/request.xml") { + id := path.Base(strings.TrimSuffix(p, "/request.xml")) + it, ok := deps.store.Get(id) + if !ok || it.RawXML == "" { + http.Error(w, "запрос не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="request-`+shortID(id)+`.xml"`) + _, _ = w.Write(faithfulXMLBytes(it.RawXML)) + return + } + // GET /admin/incoming/{id}/decision.xml — наш ответ-решение + if strings.HasSuffix(p, "/decision.xml") { + id := path.Base(strings.TrimSuffix(p, "/decision.xml")) + it, ok := deps.store.Get(id) + if !ok || it.DecisionXML == "" { + http.Error(w, "решение не найдено", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="decision-`+shortID(id)+`.xml"`) + _, _ = w.Write(faithfulXMLBytes(it.DecisionXML)) + return + } + // GET /admin/incoming/{id}/ms010.zip — ответ НРД (отчёт MS010) по поручению + if strings.HasSuffix(p, "/ms010.zip") { + id := path.Base(strings.TrimSuffix(p, "/ms010.zip")) + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "не найден", http.StatusNotFound) + return + } + writeOrderResponseZip(w, it.OrderContrDID, "otvet-nrd-ms010-"+shortID(id)) + return + } + // GET /admin/incoming/{id} + id := path.Base(p) + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "входящий перевод не найден", http.StatusNotFound) + return + } + var orderResps []orderResponse + var orderDone bool + var orderDoneAt string + if it.OrderSubmitted { + orderResps = findOrderResponses(it.OrderContrDID) + for _, rp := range orderResps { + if rp.Done { // отчёт MS010 — операция проведена + orderDone = true + orderDoneAt = rp.Time + } + } + } + data := struct { + page + It *IncomingTransfer + Flash string + OrderResponses []orderResponse + OrderDone bool + OrderDoneAt string + DraftReceived string + Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется + Quik []*QuikLink + QuikOn bool + Watch *OrderWatch + DepoL *DepoLink + }{nowPage("Входящий перевод", "incoming"), it, r.URL.Query().Get("flash"), orderResps, orderDone, orderDoneAt, draftReceivedTime(it.GUID), depoResult{}, globalQuikLinks.ForGUID(it.GUID), deps.rc.Snapshot().Quik.WritesEnabled, globalOrderWatch.Get(it.GUID), globalDepoLinks.Get(it.GUID)} + // Сверка выполняется при открытии карточки; итог сохраняем для бейджа в списке. + data.Depo = runDepoCheck(it) + if data.Depo.Ran { + deps.store.SetDepo(it.ID, data.Depo.Recommend, data.Depo.Summary) + } + render(w, a.incomingDetail, data) + }) +} + +// handleIncomingAnswer обрабатывает форму решения: по каждой бумаге radio +// confirm/reject (+ код отказа), собирает решение и отправляет роботу. +func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "не найден", http.StatusNotFound) + return + } + var decisions []SecurityDecision + for _, sec := range it.Securities { + // поле decision_ = "confirm" | "reject" + val := r.FormValue("decision_" + sec.ReferenceID) + d := SecurityDecision{ReferenceID: sec.ReferenceID, Confirm: val == "confirm"} + if !d.Confirm { + d.RejectCode = strings.TrimSpace(r.FormValue("reject_" + sec.ReferenceID)) + if d.RejectCode == "" { + d.RejectCode = "M2M05" + } + } + decisions = append(decisions, d) + } + msg := "Решение отправлено роботу" + if err := answerIncoming(r.Context(), deps, id, decisions); err != nil { + msg = "Ошибка отправки решения: " + err.Error() + } + http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) +} diff --git a/internal/lkgateway/incoming_notify.go b/internal/lkgateway/incoming_notify.go new file mode 100644 index 0000000..c83ede2 --- /dev/null +++ b/internal/lkgateway/incoming_notify.go @@ -0,0 +1,198 @@ +package lkgateway + +import ( + "encoding/json" + "fmt" + "log" + "net" + "os" + "strings" +) + +// incoming_notify.go — уведомление депозитария о входящих ценных бумагах. +// Когда по M2M нам приходит перевод (мы — принимающая сторона), кроме показа во +// «Входящих переводах» bj шлёт письмо в бэк-офис депозитария: «к вам переведена +// такая-то бумага, требуется учесть». Пока это ТОЛЬКО уведомление; позже здесь +// же будем формировать пакет для загрузки в учётную систему. + +// notifyDepositoryIncoming отправляет письмо-уведомление о новом входящем +// переводе на адреса депозитария из настроек. Best-effort (ошибки — в лог). +func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) { + if rc == nil || it == nil { + return + } + cfg := rc.Snapshot() + inc := cfg.Incoming + if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" { + return + } + smtp := cfg.Alerts.SMTP + if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" { + log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не отправлено — SMTP не настроен", it.GUID) + return + } + subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт" + body := buildIncomingEmailBody(it) + s := smtp + s.To = inc.Recipients("incoming") // получатели по маршруту «входящие переводы» + if err := sendSMTP(s, subject, body); err != nil { + log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не доставлено: %v", it.GUID, err) + return + } + log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s отправлено (%d бумаг) на %s", + it.GUID, len(it.Securities), inc.Recipients("incoming")) +} + +// notifyDepositoryAccepted отправляет письмо депозитарию о том, что входящий +// перевод обработан и ценные бумаги приняты (зачислены). Best-effort. +func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed, rejected int) { + if rc == nil || it == nil { + return + } + cfg := rc.Snapshot() + inc := cfg.Incoming + if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" { + return + } + smtp := cfg.Alerts.SMTP + if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" { + return + } + subject := "MOEX МОСТ (M2M): перевод обработан — ценные бумаги приняты (зачислены)" + var b strings.Builder + b.WriteString("Перевод по сервису MOEX МОСТ (M2M) обработан нашим депозитарием.\n\n") + if it.Auto { + b.WriteString("Проверка по учётной системе (Fancy) пройдена — перевод обработан автоматически.\n\n") + } + senderName := handbookNameByCode(it.SenderCode) + from := it.SenderCode + if it.SenderINN != "" { + from += ", ИНН " + it.SenderINN + } + if senderName != "" { + from = senderName + " (" + from + ")" + } + b.WriteString("Передающий депозитарий: " + from + "\n") + if fio := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle); fio != "" { + b.WriteString("Инвестор: " + fio + "\n") + } + b.WriteString("GUID перевода: " + it.GUID + "\n") + b.WriteString(fmt.Sprintf("Итог: принято %d, отклонено %d.\n\n", confirmed, rejected)) + b.WriteString("Ценные бумаги:\n") + for i, sec := range it.Securities { + code := sec.ISIN + if code == "" { + code = sec.SecurityCode + } + st := "принято (зачислено)" + if sec.Decision == "rejected" { + st = "отклонено (" + sec.RejectCode + ")" + } + b.WriteString(fmt.Sprintf(" %d) %s — %s шт. — %s\n", i+1, code, sec.Quantity, st)) + } + b.WriteString("\nПосмотреть перевод:\n " + adminBaseURL() + "/admin/incoming/" + it.ID + "\n") + b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n") + + m := smtp + m.To = inc.Recipients("incoming") + if err := sendSMTP(m, subject, b.String()); err != nil { + log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err) + return + } + log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, inc.Recipients("incoming")) +} + +// buildIncomingEmailBody собирает текст письма депозитарию. +func buildIncomingEmailBody(it *IncomingTransfer) string { + var b strings.Builder + b.WriteString("Уведомление о переводе ценных бумаг через сервис MOEX МОСТ (M2M).\n\n") + b.WriteString("К вам (принимающий депозитарий) переведены ценные бумаги. Требуется учесть\n") + b.WriteString("их в учёте депозитария.\n\n") + + senderName := handbookNameByCode(it.SenderCode) + from := it.SenderCode + if it.SenderINN != "" { + from += ", ИНН " + it.SenderINN + } + if senderName != "" { + from = senderName + " (" + from + ")" + } + b.WriteString("Передающий депозитарий: " + from + "\n") + b.WriteString("Идентификатор перевода (GUID): " + it.GUID + "\n") + b.WriteString("Получено: " + it.ReceivedAt.Format("02.01.2006 15:04:05") + " МСК\n\n") + + b.WriteString(fmt.Sprintf("Ценные бумаги (%d):\n", len(it.Securities))) + for i, s := range it.Securities { + code := s.ISIN + if code == "" { + code = s.SecurityCode + } + b.WriteString(fmt.Sprintf(" %d) %s — %s шт.\n", i+1, code, s.Quantity)) + } + link := adminBaseURL() + "/admin/incoming/" + it.ID + b.WriteString("\nПосмотреть входящее сообщение и ответить (принять/отклонить):\n") + b.WriteString(" " + link + "\n") + + b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n") + b.WriteString("Пакет для загрузки в учётную систему будет сформирован позже.\n") + return b.String() +} + +// adminBaseURL — базовый URL веб-админки bj для ссылок в письмах. Приоритет: +// переменная BJ_ADMIN_URL; иначе http://:<порт админки>. +func adminBaseURL() string { + if u := strings.TrimSpace(os.Getenv("BJ_ADMIN_URL")); u != "" { + return strings.TrimRight(u, "/") + } + host := primaryIPv4() + if host == "" { + host = "localhost" + } + port := "8080" + if a := strings.TrimSpace(os.Getenv("BJ_HTTP_ADDR")); a != "" { + if i := strings.LastIndex(a, ":"); i >= 0 && i+1 < len(a) { + port = a[i+1:] + } + } + return "http://" + host + ":" + port +} + +// primaryIPv4 возвращает первый непетлевой IPv4-адрес сервера (для ссылок). +func primaryIPv4() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ip4 := ipnet.IP.To4(); ip4 != nil { + return ip4.String() + } + } + } + return "" +} + +// handbookNameByCode ищет наименование участника по MC-коду в сохранённом +// справочнике (m2m-handbook.json). Пусто, если справочника нет/не найдено. +func handbookNameByCode(code string) string { + if code == "" { + return "" + } + raw, err := os.ReadFile(handbookFile) + if err != nil { + return "" + } + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + if json.Unmarshal(raw, &hb) != nil { + return "" + } + for _, p := range hb.Participants { + if p.Code == code { + return p.Name + } + } + return "" +} diff --git a/internal/lkgateway/incoming_order.go b/internal/lkgateway/incoming_order.go new file mode 100644 index 0000000..00a8578 --- /dev/null +++ b/internal/lkgateway/incoming_order.go @@ -0,0 +1,380 @@ +package lkgateway + +// incoming_order.go — подача депозитарного поручения 16/1 (форма MF010) в НРД +// как классического поручения депо (тип ЭД ORDXM, config.xml K). +// После согласия по M2M НРД кладёт нам в INBOX черновик поручения 16/1; здесь мы +// отправляем его в НРД через ИШ (WSL-канал). НРД проводит зачисление и присылает +// отчёт по поручению депо (тип XMLRP) — это и есть ожидаемый контрагентом «MS010». +// ИШ подписывает документ ГОСТ-подписью при отправке через REST API. + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +var ( + reContrDID = regexp.MustCompile(`\s*([^<\s]+)\s*`) + reTags = regexp.MustCompile(`<[^>]+>`) +) + +func decodeCP1251(b []byte) []byte { return nsdxml.DecodeWindows1251(b) } + +// faithfulXMLBytes отдаёт XML в кодировке, заявленной в его декларации: если это +// windows-1251 (как у файлов НРД), перекодирует UTF-8-строку обратно в 1251 — +// чтобы сохранённый файл открывался согласно своей же декларации. Иначе — как есть. +func faithfulXMLBytes(s string) []byte { + head := s + if len(head) > 120 { + head = head[:120] + } + if strings.Contains(strings.ToLower(head), "windows-1251") { + if enc, err := nsdxml.EncodeWindows1251([]byte(s)); err == nil { + return enc + } + } + return []byte(s) +} + +// submitDepoOrder отправляет поручение депо 16/1 (черновик MF010 из INBOX) в НРД +// типом ЭД ORDXM. По каждой бумаге НРД сформировал отдельный черновик — шлём все. +func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + it, ok := deps.store.Get(id) + if !ok { + http.Error(w, "входящий перевод не найден", http.StatusNotFound) + return + } + redirect := func(msg string) { + http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther) + } + if deps.igwClient == nil || deps.igwChan == "" { + redirect("ИШ не настроен — нельзя отправить поручение") + return + } + // только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ. + var xmls []string + for _, p := range findMF010Files(it.GUID) { + if strings.HasSuffix(strings.ToUpper(p), ".XML") { + xmls = append(xmls, p) + } + } + if len(xmls) == 0 { + redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + var sent, sentXML []string + var firstErr, contrDID string + for _, p := range xmls { + raw, e := os.ReadFile(p) // windows-1251, шлём как есть + if e != nil { + firstErr = e.Error() + continue + } + if contrDID == "" { + contrDID = orderContrDID(raw) + } + zipBody, e := igw.PackDepoOrder(raw, filepath.Base(p)) + if e != nil { + firstErr = e.Error() + continue + } + pkgID, e := deps.igwClient.SendPackage(ctx, deps.igwChan, "K", zipBody) + if e != nil { + firstErr = e.Error() + continue + } + sent = append(sent, pkgID) + sentXML = append(sentXML, string(decodeCP1251(raw))) // сохраняем что отправили (UTF-8) + } + if len(sent) > 0 { + deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n")) + if cur, ok := deps.store.Get(id); ok { + go depoOnOrderSent(deps.rc, cur.GUID, []string{contrDID}, "16/1 (зачисление)") + } + } + if len(sent) == 0 { + msg := "не удалось" + if firstErr != "" { + msg = firstErr + } + redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg) + return + } + note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+ + "Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", ")) + if firstErr != "" { + note += " ⚠ часть файлов не ушла: " + firstErr + } + redirect(note) +} + +// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД +// (отчёт MS010 + квитанции) по поручению с данным contr_d_id. +func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) { + files := collectOrderResponseRaw(contrDID) + if len(files) == 0 { + http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname)) + zw := zip.NewWriter(w) + defer zw.Close() + for _, f := range files { + if wf, e := zw.Create(f.Name); e == nil { + _, _ = wf.Write(f.Data) + } + } +} + +// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1 +// (что именно мы отправили в НРД). +func downloadSentOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) { + it, ok := deps.store.Get(id) + if !ok || strings.TrimSpace(it.OrderXML) == "" { + http.Error(w, "отправленное поручение 16/1 по этому переводу не найдено", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="poruchenie-16-1-%s.xml"`, shortID(it.ID))) + _, _ = w.Write(faithfulXMLBytes(it.OrderXML)) +} + +// draftReceivedTime — время появления черновика 16/1 (MF010) в INBOX ИШ (МСК), +// то есть когда НРД сформировал и прислал нам черновик поручения. "" если нет. +func draftReceivedTime(guid string) string { + var mt time.Time + for _, p := range findMF010Files(guid) { + if info, e := os.Stat(p); e == nil { + if mt.IsZero() || info.ModTime().Before(mt) { + mt = info.ModTime() + } + } + } + if mt.IsZero() { + return "" + } + return mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") +} + +// orderContrDID достаёт contr_d_id (исходящий номер поручения) из XML черновика. +func orderContrDID(raw []byte) string { + t := string(raw) + m := reContrDID.FindStringSubmatch(t) + if m == nil { + return "" + } + return strings.TrimSpace(m[1]) +} + +// orderResponse — ответ НРД по поданному поручению депо (найден в INBOX ИШ). +type orderResponse struct { + Pkg string // имя пакета + Kind string // человекочитаемый тип (квитанция / принятие / отчёт MS010) + Time string + Text string // краткое содержимое + Done bool // true — это отчёт MS010 (операция проведена) +} + +// findOrderResponses ищет в INBOX ИШ ответы НРД по нашему поручению (по contr_d_id). +// Читает как распакованные файлы, так и содержимое zip-архивов (отчёты НРД типа +// MS010 приходят dbf+repxml.xml внутри zip, имя пакета числовое). Исключает наши +// собственные #M2M-пакеты (черновик/запрос). +func findOrderResponses(contrDID string) []orderResponse { + contrDID = strings.TrimSpace(contrDID) + if contrDID == "" { + return nil + } + entries, err := os.ReadDir(igwInboxDir) + if err != nil { + return nil + } + type rr struct { + resp orderResponse + mt time.Time + } + var tmp []rr + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := e.Name() + if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ + continue + } + body, mt := readPkgText(filepath.Join(igwInboxDir, dir)) + if body == "" || !strings.Contains(body, contrDID) { + continue + } + kind, done := orderRespKind(dir, body) + tmp = append(tmp, rr{orderResponse{ + Pkg: dir, Kind: kind, Done: done, + Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"), + Text: orderRespSummary(body), + }, mt}) + } + sort.Slice(tmp, func(i, j int) bool { return tmp[i].mt.Before(tmp[j].mt) }) + out := make([]orderResponse, len(tmp)) + for i := range tmp { + out[i] = tmp[i].resp + } + return out +} + +// respFile — сырой файл ответа НРД (для скачивания и выгрузки регулятору). +type respFile struct { + Name string // имя внутри архива (пакет/файл) + Data []byte +} + +// collectOrderResponseRaw собирает СЫРЫЕ файлы ответов НРД по нашему поручению +// (по contr_d_id): отчёт MS010 (repxml.xml/ms010.rpt/dbf внутри zip), квитанции, +// уведомления о принятии. Именно эти файлы важны регулятору как подтверждение, +// что НРД получил и обработал поручение. +func collectOrderResponseRaw(contrDID string) []respFile { + contrDID = strings.TrimSpace(contrDID) + if contrDID == "" { + return nil + } + entries, err := os.ReadDir(igwInboxDir) + if err != nil { + return nil + } + var out []respFile + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := e.Name() + if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ + continue + } + body, _ := readPkgText(filepath.Join(igwInboxDir, dir)) + if body == "" || !strings.Contains(body, contrDID) { + continue + } + files, _ := os.ReadDir(filepath.Join(igwInboxDir, dir)) + for _, f := range files { + if f.IsDir() { + continue + } + p := filepath.Join(igwInboxDir, dir, f.Name()) + low := strings.ToLower(f.Name()) + switch { + case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"), + strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".dbf"), strings.HasSuffix(low, ".sgn"): + if b, e := os.ReadFile(p); e == nil { + out = append(out, respFile{Name: dir + "/" + f.Name(), Data: b}) + } + case strings.HasSuffix(low, ".zip"): + if b, e := os.ReadFile(p); e == nil { + if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil { + for _, zf := range zr.File { + if rc, e3 := zf.Open(); e3 == nil { + data, _ := io.ReadAll(rc) + rc.Close() + out = append(out, respFile{Name: dir + "/" + zf.Name, Data: data}) + } + } + } + } + } + } + } + return out +} + +// readPkgText собирает текст всех документов пакета: распакованные xml/htm/rpt и +// содержимое вложенных zip (декодирует windows-1251). Возвращает текст и время. +func readPkgText(dir string) (string, time.Time) { + var sb strings.Builder + var mt time.Time + files, _ := os.ReadDir(dir) + for _, f := range files { + if f.IsDir() { + continue + } + p := filepath.Join(dir, f.Name()) + if info, e := f.Info(); e == nil && info.ModTime().After(mt) { + mt = info.ModTime() + } + low := strings.ToLower(f.Name()) + switch { + case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".rpt"): + if b, e := os.ReadFile(p); e == nil { + sb.Write(decodeCP1251(b)) + sb.WriteByte('\n') + } + case strings.HasSuffix(low, ".zip"): + if b, e := os.ReadFile(p); e == nil { + if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil { + for _, zf := range zr.File { + zl := strings.ToLower(zf.Name) + if strings.HasSuffix(zl, ".xml") || strings.HasSuffix(zl, ".rpt") || strings.HasSuffix(zl, ".htm") { + if rc, e3 := zf.Open(); e3 == nil { + data, _ := io.ReadAll(rc) + rc.Close() + sb.Write(decodeCP1251(data)) + sb.WriteByte('\n') + } + } + } + } + } + } + } + return sb.String(), mt +} + +// orderRespKind классифицирует ответ НРД по содержимому (надёжнее префикса имени). +// done=true — это отчёт MS010 (операция проведена). +func orderRespKind(pkg, body string) (string, bool) { + u := strings.ToUpper(body) + low := strings.ToLower(body) + switch { + case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025"): + return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true + case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"): + return "Ошибка обработки пакета (CERROR)", false + case strings.Contains(u, "REORD") || strings.Contains(low, "принят"): + return "Уведомление о принятии поручения депо (REORD)", false + case strings.Contains(u, "XCONF") || strings.Contains(low, "квитанц") || strings.Contains(low, "получен"): + return "Квитанция о получении пакета (XCONF)", false + } + // fallback по первой букве имени пакета + switch { + case strings.HasPrefix(pkg, "K"): + return "Уведомление о принятии поручения депо (REORD)", false + case strings.HasPrefix(pkg, "C"): + return "Квитанция о получении пакета (XCONF)", false + } + return "Ответ НРД (" + pkg + ")", false +} + +func orderRespSummary(body string) string { + txt := reTags.ReplaceAllString(body, " ") + txt = strings.Join(strings.Fields(txt), " ") + if len(txt) > 220 { + txt = txt[:220] + } + return txt +} diff --git a/internal/lkgateway/ish_version.go b/internal/lkgateway/ish_version.go new file mode 100644 index 0000000..2fc246e --- /dev/null +++ b/internal/lkgateway/ish_version.go @@ -0,0 +1,218 @@ +package lkgateway + +// ish_version.go — определение установленной версии Интеграционного шлюза НРД +// (ИШ) и сравнение с версией, доступной на сайте НРД. +// +// Установленная версия берётся из igate.deps.json (world-readable), доступная — +// со страницы дистрибутивов НРД. ВАЖНО: на странице соседствуют сборки под разные +// ОС с РАЗНЫМИ номерами (на момент внедрения Windows x86/x64 = 107.0.0.933, а +// Astra-Linux/amd64 = 107.0.0.930). Мы работаем на Linux (.deb, amd64), поэтому +// сравнивать нужно именно с Linux-сборкой, иначе система ложно покажет «доступно +// обновление» на версию, которой под нашу ОС ещё нет. + +import ( + "context" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" +) + +// ishDepsPath — файл манифеста .NET-сборки ИШ; содержит "igate/<версия>". +const ishDepsPath = "/opt/nsd/igate/igate/igate.deps.json" + +// ishSiteURL — страница дистрибутивов ПО НРД (там же ИШ). +const ishSiteURL = "https://www.nsd.ru/workflow/system/programs/web-service/" + +var ( + ishVerRe = regexp.MustCompile(`"igate/(\d+(?:\.\d+){2,3})"`) + ishAstraRe = regexp.MustCompile(`Astra-Linux[^"]*?(\d{2,3}(?:\.\d+){3})`) + ishAnyRe = regexp.MustCompile(`\d{2,3}(?:\.\d+){3}`) +) + +// IshVersionInfo — сводка по версиям ИШ для отображения в интерфейсе. +type IshVersionInfo struct { + Installed string `json:"installed"` // напр. 107.0.930 (из deps.json) + SiteLinux string `json:"site_linux,omitempty"` // Astra-Linux сборка на сайте НРД + SiteLatest string `json:"site_latest,omitempty"` // самая новая любая ОС (инфо) + UpToDate bool `json:"up_to_date"` // installed соответствует Linux-сборке + Note string `json:"note,omitempty"` // человекочитаемый комментарий + CheckedAt time.Time `json:"checked_at"` +} + +// ishInstalledVersion читает версию ИШ из deps.json. Пусто, если ИШ не найден. +func ishInstalledVersion() string { + b, err := os.ReadFile(ishDepsPath) + if err != nil { + return "" + } + m := ishVerRe.FindSubmatch(b) + if m == nil { + return "" + } + return string(m[1]) +} + +// canonVer приводит версию к форме без внутренних нулей: 107.0.0.930 и 107.0.930 +// дают одинаковый результат (сборочная версия .deb опускает нулевой компонент). +func canonVer(v string) string { + p := strings.Split(strings.TrimSpace(v), ".") + if len(p) < 2 { + return v + } + core := []string{p[0]} + for _, x := range p[1 : len(p)-1] { + if x != "0" { + core = append(core, x) + } + } + core = append(core, p[len(p)-1]) + return strings.Join(core, ".") +} + +var ( + ishVerMu sync.Mutex + ishVerCache *IshVersionInfo + ishVerTTL = 6 * time.Hour +) + +// ishVersionInfo возвращает сводку по версиям ИШ (с кэшем на 6ч, чтобы не дёргать +// сайт НРД на каждый показ статуса). Сетевая часть best-effort: при недоступности +// сайта возвращается только установленная версия. +func ishVersionInfo(ctx context.Context) IshVersionInfo { + ishVerMu.Lock() + if ishVerCache != nil && time.Since(ishVerCache.CheckedAt) < ishVerTTL { + c := *ishVerCache + ishVerMu.Unlock() + return c + } + ishVerMu.Unlock() + + info := IshVersionInfo{ + Installed: ishInstalledVersion(), + CheckedAt: time.Now().UTC(), + } + linux, latest := ishSiteVersions(ctx) + info.SiteLinux, info.SiteLatest = linux, latest + + switch { + case info.Installed == "": + info.Note = "ИШ не обнаружен (deps.json недоступен)" + case linux == "": + info.UpToDate = true // не с чем сравнивать — не тревожим + info.Note = "версия на сайте НРД недоступна (проверьте вручную)" + case canonVer(info.Installed) == canonVer(linux): + info.UpToDate = true + info.Note = "актуальная версия для Linux (Astra/amd64)" + if latest != "" && canonVer(latest) != canonVer(linux) { + info.Note += "; для Windows доступна " + latest + } + default: + info.UpToDate = false + info.Note = "доступно обновление до " + linux + " (Astra-Linux)" + } + + ishVerMu.Lock() + ishVerCache = &info + ishVerMu.Unlock() + return info +} + +// ishSiteVersions скачивает страницу НРД и возвращает (Linux-сборка, самая новая +// по всем ОС). Обе строки пусты при ошибке сети/парсинга. +func ishSiteVersions(ctx context.Context) (linux, latest string) { + c := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ishSiteURL, nil) + if err != nil { + return "", "" + } + // WAF сайта НРД отвечает 403 на «Mozilla»-UA без заголовка Accept (эвристика + // анти-бот). Отправляем полноценный браузерный набор заголовков. + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8") + resp, err := c.Do(req) + if err != nil { + return "", "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "" + } + body := make([]byte, 0, 256*1024) + buf := make([]byte, 32*1024) + for len(body) < 4*1024*1024 { // не читаем больше 4 МБ + n, rerr := resp.Body.Read(buf) + body = append(body, buf[:n]...) + if rerr != nil { + break + } + } + if m := ishAstraRe.FindSubmatch(body); m != nil { + linux = string(m[1]) + } + // Самая новая версия по всем сборкам (для информационной строки). + var best string + for _, v := range ishAnyRe.FindAllString(string(body), -1) { + if best == "" || verLess(best, v) { + best = v + } + } + latest = best + return linux, latest +} + +// verLess сравнивает версии покомпонентно как числа: verLess(a,b)=true если a '9' { + return n + } + n = n*10 + int(r-'0') + } + return n +} + +// ishVersionStatus — версия ИШ как отдельная проверка для панели статуса. +func ishVersionStatus(ctx context.Context) Status { + info := ishVersionInfo(ctx) + s := Status{Name: "Версия Интеграционного шлюза", Optional: true} + if info.Installed == "" { + s.OK = true + s.Message = "ИШ не обнаружен на этом сервере" + return s + } + s.OK = info.UpToDate + if info.UpToDate { + s.Message = "установлена " + info.Installed + " — " + info.Note + } else { + s.Message = "установлена " + info.Installed + "; " + info.Note + } + if info.SiteLinux != "" { + s.Detail = "на сайте НРД (Linux): " + info.SiteLinux + if info.SiteLatest != "" && info.SiteLatest != info.SiteLinux { + s.Detail += ", последняя (Windows): " + info.SiteLatest + } + } + return s +} diff --git a/internal/lkgateway/journal.go b/internal/lkgateway/journal.go new file mode 100644 index 0000000..befc784 --- /dev/null +++ b/internal/lkgateway/journal.go @@ -0,0 +1,113 @@ +package lkgateway + +// journal.go — объединённый журнал обмена: входящие + исходящие переводы в одной +// хронологической ленте (плюс отдельные виды — «Исходящие» и «Входящие» на своих +// страницах). Для быстрого обзора всего потока с контрагентами. + +import ( + "fmt" + "net/http" + "sort" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// journalRow — одна строка объединённого журнала. +type journalRow struct { + Time time.Time + Dir string // "Исходящий" | "Входящий" + DirKind string // "out" | "in" + Party string // контрагент (наименование + код/ИНН) + SecN int + Status string // класс бейджа (для цвета) + StatusText string // человекочитаемый текст статуса (русский) + Outcome string // для входящих: «Принято» / «Отказано» / «Частично …» + Href string +} + +// incomingJournalStatus — статус входящего для журнала: класс бейджа (цвет), +// текст и исход по бумагам (принято/отказано/частично). +func incomingJournalStatus(it *IncomingTransfer) (class, text, outcome string) { + if it.Status != "answered" { + return "pending", "Ждёт решения", "" + } + conf, rej := 0, 0 + for _, s := range it.Securities { + switch s.Decision { + case "confirmed": + conf++ + case "rejected": + rej++ + } + } + switch { + case rej == 0 && conf > 0: + return "confirmed", "Отвечено", "Принято" + case conf == 0 && rej > 0: + return "rejected", "Отвечено", "Отказано" + case conf > 0 && rej > 0: + return "awaiting_decision", "Отвечено", fmt.Sprintf("Частично: принято %d, отказано %d", conf, rej) + } + return "confirmed", "Отвечено", "" +} + +// incomingStatusVM — статус входящего для шаблонов (класс бейджа + текст + исход). +type incomingStatusVM struct{ Class, Text, Outcome string } + +// incomingStatusOf — статус входящего для страницы «Входящие» (template-func). +func incomingStatusOf(it *IncomingTransfer) incomingStatusVM { + c, t, o := incomingJournalStatus(it) + return incomingStatusVM{Class: c, Text: t, Outcome: o} +} + +// registerJournal вешает /admin/journal — объединённая лента вход+исход. +func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) { + mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) { + var rows []journalRow + + // Исходящие (наши заявки в НРД). + if page, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 100}); err == nil { + for _, c := range page.Items { + party := m2mPartyName(c.ReceivingDepositoryINN) + if party != "" { + party += " · ИНН " + c.ReceivingDepositoryINN + } else { + party = "ИНН " + c.ReceivingDepositoryINN + } + rows = append(rows, journalRow{ + Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out", + Party: party, SecN: len(c.Securities), + Status: c.Status, StatusText: russianState(c.Status), + Href: "/admin/claims/" + c.ID, + }) + } + } + // Входящие (переводы к нам). + for _, it := range store.List() { + party := m2mPartyName(it.SenderCode) + if party == "" { + party = m2mPartyName(it.SenderINN) + } + if party != "" { + party += " · " + it.SenderCode + } else { + party = it.SenderCode + } + cls, txt, outcome := incomingJournalStatus(it) + rows = append(rows, journalRow{ + Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in", + Party: party, SecN: len(it.Securities), + Status: cls, StatusText: txt, Outcome: outcome, + Href: "/admin/incoming/" + it.ID, + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) }) + + data := struct { + page + Rows []journalRow + }{nowPage("Журнал обмена", "journal"), rows} + render(w, a.journal, data) + }) +} diff --git a/internal/lkgateway/keywizard.go b/internal/lkgateway/keywizard.go index ad5c185..9d19c6f 100644 --- a/internal/lkgateway/keywizard.go +++ b/internal/lkgateway/keywizard.go @@ -1,6 +1,7 @@ package lkgateway import ( + "bytes" "context" "encoding/json" "fmt" @@ -29,7 +30,6 @@ func urlQ(s string) string { return url.QueryEscape(s) } // VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий // sudoers (bj-server работает под непривилегированным bj). - // keyWizardStep — один шаг мастера. type keyWizardStep struct { Title string @@ -232,6 +232,9 @@ func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) } password := r.FormValue("password") + // Не копим staging: чистим прошлые распаковки/архивы перед новой загрузкой. + sweepStaging() + isoDir := "/var/lib/bj/iso" if err := os.MkdirAll(isoDir, 0o755); err != nil { h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error()) @@ -332,8 +335,11 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) tgt, _ := res["target"].(string) spr, _ := res["spr"].(string) h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup)) - crl, _ := res["crl"].(string) - h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl))) + rebuild, _ := res["rebuild"].(string) + igate, _ := res["igate"].(string) + igp, _ := res["igate_profile_dir"].(string) + h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» пересобран (%s); профиль ИШ %s %s", + fallback(spr, "—"), rebuildRu(rebuild), igateRu(igate), igp)) h.keyWiz.set(3, "active", "") h.keyWiz.mu.Unlock() @@ -354,6 +360,9 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) h.keyWiz.Done = true h.keyWiz.mu.Unlock() + // Ключ уже на флешке и в ИШ — распаковка staging больше не нужна. + _ = os.RemoveAll(staging) + http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther) } @@ -370,7 +379,7 @@ func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) deadline := time.Now().Add(40 * time.Second) for time.Now().Before(deadline) { if st := h.ishEngineState(ctx); st != "" { - return true, "engine "+st + return true, "engine " + st } select { case <-ctx.Done(): @@ -418,6 +427,36 @@ func crlRu(s string) string { } } +// rebuildRu — статус пересборки справочника сертификатов на флешке. +func rebuildRu(s string) string { + switch { + case s == "ok": + return "совместимый формат, сертификаты извлечены из архива" + case s == "skip": + return "пропущено" + case strings.HasPrefix(s, "fail"): + return "ошибка пересборки (" + strings.TrimPrefix(s, "fail: ") + ")" + default: + return s + } +} + +// igateRu — статус формирования профиля для ИШ. +func igateRu(s string) string { + switch s { + case "connected": + return "сформирован, зарегистрирован и подключён к ИШ (cryptography.profile):" + case "ok": + return "сформирован и зарегистрирован (но не выбран в ИШ):" + case "skip": + return "не формировался" + case "ini-fail": + return "сформирован, но регистрация в zpki.ini не удалась:" + default: + return s + } +} + // reactivateCryptoProfile повторно активирует текущий серверный профиль // bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается). // Best-effort: возвращает true при успехе. @@ -448,6 +487,24 @@ func boolRu(b bool, yes, no string) string { return no } +// sweepStaging удаляет прошлые распаковки (/var/lib/bj/media/iso/*) и архивы +// (/var/lib/bj/iso/*.7z|zip), чтобы staging не копился. Вызывается перед новой +// загрузкой — текущая распаковка создаётся уже после очистки и не страдает. +func sweepStaging() { + if entries, err := os.ReadDir("/var/lib/bj/media/iso"); err == nil { + for _, e := range entries { + _ = os.RemoveAll(filepath.Join("/var/lib/bj/media/iso", e.Name())) + } + } + for _, pat := range []string{"/var/lib/bj/iso/*.7z", "/var/lib/bj/iso/*.zip"} { + if files, err := filepath.Glob(pat); err == nil { + for _, f := range files { + _ = os.Remove(f) + } + } + } +} + const keymediaReqDir = "/var/lib/bj/keymedia-requests" // runKeymediaHelper передаёт запрос привилегированному воркеру через файловый @@ -456,15 +513,23 @@ const keymediaReqDir = "/var/lib/bj/keymedia-requests" // и пишет .res. bj-server опрашивает результат. Так привилегированная // работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB. func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt string) (map[string]any, error) { + return runKeymediaOp(ctx, action, map[string]string{"staging": staging, "profile": profile, "target_dev": targetDev, "target_mnt": targetMnt}) +} + +// runKeymediaOp — общий вход к root-воркеру: action + произвольные поля +// запроса (staging, profile, target_dev, target_mnt, extra). Используется и +// мастером ключа, и автообновлением сертификатов НРД (nsdcerts.go). +func runKeymediaOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) { id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano()) reqPath := filepath.Join(keymediaReqDir, id+".req") resPath := filepath.Join(keymediaReqDir, id+".res") defer os.Remove(resPath) - reqBody, _ := json.Marshal(map[string]string{ - "action": action, "staging": staging, "profile": profile, - "target_dev": targetDev, "target_mnt": targetMnt, - }) + req := map[string]string{"action": action} + for k, v := range fields { + req[k] = v + } + reqBody, _ := json.Marshal(req) // Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл. tmp := reqPath + ".tmp" if err := os.WriteFile(tmp, reqBody, 0o660); err != nil { @@ -487,6 +552,10 @@ func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, continue // ещё не готово } res := map[string]any{} + // systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{». + if i := bytes.IndexByte(b, '{'); i > 0 { + b = b[i:] + } if jerr := json.Unmarshal(b, &res); jerr != nil { return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b))) } diff --git a/internal/lkgateway/licensecheck.go b/internal/lkgateway/licensecheck.go index 3d75d84..b1ddbbe 100644 --- a/internal/lkgateway/licensecheck.go +++ b/internal/lkgateway/licensecheck.go @@ -1,79 +1,153 @@ package lkgateway import ( + "strings" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" ) -// DefaultLicensePublicKey — публичный ключ лицензий, зашитый в релиз. -// Пустой в исходниках; подставляется при официальной сборке. Если задан в -// настройках (LicenseSettings.PublicKey) — приоритет у настроек. -var DefaultLicensePublicKey = "" +// DefaultLicensePublicKey — открытый ключ проверки лицензий: пара к ключу подписи сервера +// лицензий lic.zetit.ru. Зашит в bj и в настройках не меняется, поэтому ключ, подписанный +// чем-то другим, не принимается. var — только для подмены в тестах. +var DefaultLicensePublicKey = "UuvEYFxxzOFk59FjCQTdxKuzkNAJpJyWTuUdlCYfsdI=" // LicenseStatus — сводка состояния лицензии для UI и гейтов. type LicenseStatus struct { - Present bool // ключ введён - Valid bool // подпись верна и срок не истёк + Present bool // ключ введён + Valid bool // подпись верна, срок не истёк, ключ этой установки + Online bool // выдан сервером лицензий под эту установку и продлевается автоматически + LicenseID string Tenant string Plan string ExpiresAt time.Time + ContractUntil time.Time DaysLeft int AllowsUpdates bool + Features []string // признаки (модули), разрешённые ключом Message string } -// licensingEnabled — включено ли лицензирование (есть публичный ключ для -// проверки). Если ключа нет вовсе — продукт в открытом режиме, гейты не -// действуют (удобно для разработки и бесплатной редакции). -func licensingEnabled(rc *RuntimeConfig) bool { - return rc.Snapshot().License.PublicKey != "" || DefaultLicensePublicKey != "" +// LicenseFeatureView — признак лицензии для отображения. +type LicenseFeatureView struct { + Key, Title string + On bool +} + +// knownFeatures — известные признаки (модули) и их названия. +var knownFeatures = []struct{ Key, Title string }{ + {"updates", "Обновления через артефакторию"}, + {"web-cabinet", "Личный кабинет клиента (bj-lk)"}, + {"quik-mnp", "QUIK: модуль неторговых поручений (цикл M2M)"}, + {"quik-export", "QUIK: экспорт биржевой информации"}, + {"npr", "Риск-монитор НПР / УДС"}, +} + +func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView { + out := make([]LicenseFeatureView, 0, len(knownFeatures)) + for _, f := range knownFeatures { + out = append(out, LicenseFeatureView{Key: f.Key, Title: f.Title, On: moduleAllowed(rc, f.Key)}) + } + st := licenseStatus(rc) + for _, f := range st.Features { + known := false + for _, k := range knownFeatures { + if k.Key == f { + known = true + } + } + if !known { + out = append(out, LicenseFeatureView{Key: f, Title: f, On: st.Valid}) + } + } + return out +} + +func modulesAllowed(rc *RuntimeConfig) map[string]bool { + m := map[string]bool{} + for _, f := range knownFeatures { + m[f.Key] = moduleAllowed(rc, f.Key) + } + return m +} + +// moduleAllowed — разрешён ли модуль (лицензионный признак). План enterprise — все модули. +func moduleAllowed(rc *RuntimeConfig, feature string) bool { + st := licenseStatus(rc) + if !st.Valid { + return false + } + if st.Plan == string(license.PlanEnterprise) { + return true + } + for _, f := range st.Features { + if f == feature { + return true + } + } + return false +} + +// licensingEnabled — лицензирование действует всегда: ключ проверки зашит в bj. +func licensingEnabled(_ *RuntimeConfig) bool { return DefaultLicensePublicKey != "" } + +// verifyLicenseToken — подпись ключа зашитым открытым ключом (срок не проверяется). +func verifyLicenseToken(key string) (*license.License, error) { + pub, err := license.ParsePublicKey(DefaultLicensePublicKey) + if err != nil { + return nil, err + } + tok, err := license.DecodeToken(key) + if err != nil { + return nil, err + } + return license.Verify(tok, pub) } // licenseStatus разбирает и проверяет лицензию из настроек. func licenseStatus(rc *RuntimeConfig) LicenseStatus { s := rc.Snapshot().License st := LicenseStatus{} - if s.Key == "" { - st.Message = "лицензионный ключ не введён" + if strings.TrimSpace(s.Key) == "" { + st.Message = "лицензия не активирована" return st } st.Present = true - - pubB64 := s.PublicKey - if pubB64 == "" { - pubB64 = DefaultLicensePublicKey - } - if pubB64 == "" { - st.Message = "нет публичного ключа для проверки лицензии" - return st - } - pub, err := license.ParsePublicKey(pubB64) + lic, err := verifyLicenseToken(s.Key) if err != nil { - st.Message = "неверный публичный ключ: " + err.Error() - return st - } - tok, err := license.DecodeToken(s.Key) - if err != nil { - st.Message = "неверный формат ключа: " + err.Error() - return st - } - lic, err := license.Verify(tok, pub) - if err != nil { - st.Message = "подпись лицензии недействительна" + st.Message = "ключ недействителен: подпись не сходится или ключ повреждён" return st } now := time.Now().UTC() + st.LicenseID = lic.ID st.Tenant = lic.Tenant st.Plan = string(lic.Plan) st.ExpiresAt = lic.ExpiresAt + st.ContractUntil = lic.ContractUntil st.DaysLeft = lic.DaysLeft(now) st.AllowsUpdates = lic.AllowsUpdates() + st.Features = lic.Features + st.Online = lic.InstallID != "" + if lic.Product != "" && lic.Product != "bj-server" { + st.Message = "ключ выпущен для другого продукта (" + lic.Product + ")" + return st + } + if st.Online && !strings.EqualFold(lic.InstallID, s.InstallID) { + st.Message = "ключ выдан другой установке: активируйте эту установку кодом" + return st + } if err := lic.Valid(now); err != nil { st.Message = err.Error() return st } st.Valid = true - st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006") + if st.Online { + st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006") + ", продлевается автоматически" + if !lic.ContractUntil.IsZero() { + st.Message += "; договор до " + lic.ContractUntil.Format("02.01.2006") + } + } else { + st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006") + " (офлайн-ключ)" + } return st } diff --git a/internal/lkgateway/licenseclient.go b/internal/lkgateway/licenseclient.go new file mode 100644 index 0000000..21c4686 --- /dev/null +++ b/internal/lkgateway/licenseclient.go @@ -0,0 +1,207 @@ +package lkgateway + +// Онлайн-лицензирование: активация установки кодом и ежесуточное продление ключа на +// сервере лицензий (docs/license-server-api.md). Адрес сервера и открытый ключ зашиты в bj +// и в настройках не меняются. Офлайн-ключ для закрытого контура вставляется вручную. + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +// licenseServer — сервер лицензий. var — только для подмены в тестах. +var licenseServer = "https://lic.zetit.ru" + +var licenseHTTP = &http.Client{Timeout: 25 * time.Second} + +// errLicenseOffline — у установки офлайн-ключ, продлевать на сервере нечего. +var errLicenseOffline = errors.New("ключ офлайн (закрытый контур): продление на сервере лицензий не выполняется") + +var licenseAlertAt time.Time + +// ensureInstallID — идентификатор установки: создаётся один раз и хранится в настройках. +func ensureInstallID(rc *RuntimeConfig) string { + s := rc.Snapshot().License + if uuidRe.MatchString(s.InstallID) { + return strings.ToLower(s.InstallID) + } + b := make([]byte, 16) + _, _ = rand.Read(b) + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + h := hex.EncodeToString(b) + s.InstallID = h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] + if err := rc.SaveLicense(s); err != nil { + log.Printf("lk-gateway: лицензия: сохранение идентификатора установки: %v", err) + } + return s.InstallID +} + +// machineHash — sha256 от /etc/machine-id: серверу лицензий, чтобы заметить копию сервера. +func machineHash() string { + b, err := os.ReadFile("/etc/machine-id") + if err != nil || strings.TrimSpace(string(b)) == "" { + return "" + } + sum := sha256.Sum256([]byte(strings.TrimSpace(string(b)))) + return hex.EncodeToString(sum[:]) +} + +type licenseAPIError struct { + Status int `json:"-"` + Code string `json:"error"` + Message string `json:"message"` +} + +func (e *licenseAPIError) Error() string { return e.Message } + +type licenseReply struct { + Token string `json:"token"` + LicenseID string `json:"license_id"` + Tenant string `json:"tenant"` + ExpiresAt time.Time `json:"expires_at"` + ContractUntil time.Time `json:"contract_until"` +} + +func licenseCall(ctx context.Context, path string, body map[string]string) (*licenseReply, error) { + host, _ := os.Hostname() + body["hostname"], body["version"], body["machine"] = host, BuildVersion, machineHash() + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, licenseServer+path, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "bj-server/"+BuildVersion) + resp, err := licenseHTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("сервер лицензий недоступен: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + var e licenseAPIError + if json.Unmarshal(raw, &e) == nil && e.Code != "" { + e.Status = resp.StatusCode + return nil, &e + } + return nil, fmt.Errorf("сервер лицензий: HTTP %d", resp.StatusCode) + } + var r licenseReply + if err := json.Unmarshal(raw, &r); err != nil || r.Token == "" { + return nil, fmt.Errorf("сервер лицензий вернул непонятный ответ") + } + return &r, nil +} + +// licenseRecord — итог обращения к серверу лицензий (для карточки); token != "" — новый ключ. +func licenseRecord(rc *RuntimeConfig, token, msg string, ok bool) error { + s := rc.Snapshot().License + now := time.Now() + s.LastAttempt, s.LastOK, s.LastMsg = now, ok, msg + if ok { + s.LastSync = now + } + if token != "" { + s.Key = token + } + return rc.SaveLicense(s) +} + +// licenseAccept — проверить ключ от сервера (подпись зашитым ключом, эта установка) и сохранить. +func licenseAccept(rc *RuntimeConfig, token, how string) error { + installID := ensureInstallID(rc) + lic, err := verifyLicenseToken(token) + if err != nil { + return fmt.Errorf("сервер вернул недействительный ключ: %w", err) + } + if !strings.EqualFold(lic.InstallID, installID) { + return fmt.Errorf("сервер вернул ключ для другой установки") + } + return licenseRecord(rc, token, fmt.Sprintf("%s: ключ до %s", how, lic.ExpiresAt.Local().Format("02.01.2006")), true) +} + +// LicenseActivate — активация этой установки кодом на сервере лицензий. +func LicenseActivate(ctx context.Context, rc *RuntimeConfig, code string) error { + installID := ensureInstallID(rc) + r, err := licenseCall(ctx, "/v1/activate", map[string]string{"code": strings.TrimSpace(code), "install_id": installID}) + if err != nil { + _ = licenseRecord(rc, "", "активация не удалась: "+err.Error(), false) + return err + } + if err := licenseAccept(rc, r.Token, "активирована"); err != nil { + _ = licenseRecord(rc, "", err.Error(), false) + return err + } + log.Printf("lk-gateway: лицензия активирована на сервере лицензий: %s, лицензия %s", r.Tenant, r.LicenseID) + return nil +} + +// LicenseRenew — продление ключа этой установки. +func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error { + installID := ensureInstallID(rc) + cur := rc.Snapshot().License.Key + lic, err := verifyLicenseToken(cur) + if err != nil || lic.InstallID == "" { + return errLicenseOffline + } + r, err := licenseCall(ctx, "/v1/renew", map[string]string{"token": cur, "install_id": installID}) + if err != nil { + _ = licenseRecord(rc, "", "продление не удалось: "+err.Error(), false) + var ae *licenseAPIError + if errors.As(err, &ae) && time.Since(licenseAlertAt) > 12*time.Hour { + licenseAlertAt = time.Now() + raiseSystemAlert(rc, "Лицензия: сервер лицензий отказал в продлении", + fmt.Sprintf("%s (%s). Текущий ключ действует до %s; после этого модули по лицензии отключатся.", ae.Message, ae.Code, lic.ExpiresAt.Local().Format("02.01.2006"))) + } + return err + } + return licenseAccept(rc, r.Token, "продлена") +} + +// licenseNeedsRenew — онлайн-ключ, и сутки без успешного продления. +func licenseNeedsRenew(rc *RuntimeConfig) bool { + s := rc.Snapshot().License + lic, err := verifyLicenseToken(s.Key) + if err != nil || lic.InstallID == "" { + return false + } + return time.Since(s.LastSync) >= 23*time.Hour +} + +// licenseLoop — раз в час проверяет, пора ли продлить ключ (продление — раз в сутки, +// при сбое — повтор через час). +func (s *Server) licenseLoop(ctx context.Context) { + ensureInstallID(s.rc) + wait := 2 * time.Minute + for { + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + wait = time.Hour + if !licenseNeedsRenew(s.rc) { + continue + } + cctx, cancel := context.WithTimeout(ctx, time.Minute) + if err := LicenseRenew(cctx, s.rc); err != nil { + log.Printf("lk-gateway: лицензия: %v", err) + } else { + log.Printf("lk-gateway: лицензия продлена: %s", s.rc.Snapshot().License.LastMsg) + } + cancel() + } +} diff --git a/internal/lkgateway/licenseclient_test.go b/internal/lkgateway/licenseclient_test.go new file mode 100644 index 0000000..044ab0b --- /dev/null +++ b/internal/lkgateway/licenseclient_test.go @@ -0,0 +1,137 @@ +package lkgateway + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" +) + +// testLicenseKey подменяет зашитый ключ проверки тестовой парой. +func testLicenseKey(t *testing.T) ed25519.PrivateKey { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + prev := DefaultLicensePublicKey + DefaultLicensePublicKey = base64.StdEncoding.EncodeToString(pub) + t.Cleanup(func() { DefaultLicensePublicKey = prev }) + return priv +} + +func testRC(t *testing.T) *RuntimeConfig { + t.Helper() + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + return rc +} + +func signTestLicense(t *testing.T, priv ed25519.PrivateKey, l *license.License) string { + t.Helper() + tok, err := license.Sign(l, priv, "main") + if err != nil { + t.Fatal(err) + } + return tok.Encode() +} + +func TestLicenseActivateAndRenew(t *testing.T) { + priv := testLicenseKey(t) + rc := testRC(t) + var gotInstall string + renewed := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var in map[string]string + _ = json.NewDecoder(r.Body).Decode(&in) + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/v1/activate" && in["code"] != "BJ-TEST-CODE-0001" { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"unknown_code","message":"код активации не найден"}`)) + return + } + if r.URL.Path == "/v1/renew" { + renewed++ + } + gotInstall = in["install_id"] + now := time.Now().UTC() + tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro, + IssuedAt: now, ExpiresAt: now.Add(60 * 24 * time.Hour), Features: []string{"npr"}, InstallID: in["install_id"], ContractUntil: now.AddDate(1, 0, 0)}) + _ = json.NewEncoder(w).Encode(map[string]any{"token": tok, "license_id": "lic-1", "tenant": "Тест"}) + })) + defer srv.Close() + prev := licenseServer + licenseServer = srv.URL + t.Cleanup(func() { licenseServer = prev }) + ctx := context.Background() + + if err := LicenseActivate(ctx, rc, "BJ-XXXX-XXXX-XXXX"); err == nil || !strings.Contains(err.Error(), "не найден") { + t.Fatalf("неверный код: ожидали отказ сервера, получили %v", err) + } + if licenseStatus(rc).Present { + t.Fatal("после отказа ключ не должен сохраняться") + } + if err := LicenseActivate(ctx, rc, "BJ-TEST-CODE-0001"); err != nil { + t.Fatal(err) + } + st := licenseStatus(rc) + if !st.Valid || !st.Online || st.Tenant != "Тест" { + t.Fatalf("после активации: %+v", st) + } + if gotInstall == "" || gotInstall != rc.Snapshot().License.InstallID { + t.Fatalf("install_id в запросе %q, в настройках %q", gotInstall, rc.Snapshot().License.InstallID) + } + if !moduleAllowed(rc, "npr") || moduleAllowed(rc, "quik-mnp") { + t.Fatal("модули должны следовать признакам лицензии pro") + } + if licenseNeedsRenew(rc) { + t.Fatal("сразу после активации продлевать не нужно") + } + if err := LicenseRenew(ctx, rc); err != nil || renewed != 1 { + t.Fatalf("продление: %v, обращений %d", err, renewed) + } + + s := rc.Snapshot().License + s.InstallID = "00000000-0000-4000-8000-000000000000" + _ = rc.SaveLicense(s) + if st := licenseStatus(rc); st.Valid || !strings.Contains(st.Message, "другой установке") { + t.Fatalf("ключ чужой установки должен быть недействителен: %+v", st) + } +} + +func TestLicenseOfflineEnterpriseAndForeignKey(t *testing.T) { + priv := testLicenseKey(t) + rc := testRC(t) + now := time.Now().UTC() + s := rc.Snapshot().License + s.Key = signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-2", Tenant: "Офлайн", Product: "bj-server", Plan: license.PlanEnterprise, + IssuedAt: now, ExpiresAt: now.AddDate(1, 0, 0)}) + _ = rc.SaveLicense(s) + st := licenseStatus(rc) + if !st.Valid || st.Online { + t.Fatalf("офлайн-ключ: %+v", st) + } + if !moduleAllowed(rc, "quik-export") || !moduleAllowed(rc, "npr") { + t.Fatal("enterprise должен открывать все модули") + } + if err := LicenseRenew(context.Background(), rc); err != errLicenseOffline { + t.Fatalf("офлайн-ключ не продлевается: %v", err) + } + + _, other, _ := ed25519.GenerateKey(nil) + s.Key = signTestLicense(t, other, &license.License{Schema: 1, ID: "x", Plan: license.PlanEnterprise, IssuedAt: now, ExpiresAt: now.AddDate(1, 0, 0)}) + _ = rc.SaveLicense(s) + if licenseStatus(rc).Valid || moduleAllowed(rc, "npr") { + t.Fatal("ключ, подписанный чужим ключом, не должен приниматься") + } +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go new file mode 100644 index 0000000..febc07e --- /dev/null +++ b/internal/lkgateway/lkapi.go @@ -0,0 +1,383 @@ +package lkgateway + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "strings" + "sync" + "time" +) + +// API для модуля Личного кабинета клиента (bj-lk). Модуль ставится на веб-сервер +// клиента, сам к БД не ходит — только сюда, с ключом X-BJ-Key (задаётся при +// установке). Клиент внутри модуля логинится по логину/паролю; сессии живут тут. +// +// POST /api/lk/login — вход клиента (логин/пароль) → session token +// GET /api/lk/portfolio — портфель клиента (остатки бумаг из Fancy/Спектр) +// POST /api/lk/transfer — заявка на перевод бумаг другому брокеру +// GET /api/lk/claims — статусы заявок клиента +// GET /api/lk/ping — проверка связи модуля с bj-server + +// Сессии ЛК живут дольше рабочего дня и ПРОДЛЕВАЮТСЯ при каждом обращении +// (скользящий срок), поэтому активного клиента из кабинета не выкидывает. +const lkSessionTTL = 12 * time.Hour + +// lkSessionsFile — сессии переживают перезапуск bj-server. Раньше они были +// только в памяти, и любой рестарт (обновление, деплой) выбрасывал всех +// клиентов с «сессия истекла, войдите заново». +const lkSessionsFile = "/var/lib/bj/.bj/lk-sessions.json" + +type lkSession struct { + user LKUser + expiresAt time.Time +} + +type lkSessions struct { + mu sync.Mutex + data map[string]lkSession +} + +func newLKSessions() *lkSessions { + s := &lkSessions{data: map[string]lkSession{}} + s.load() + return s +} + +// persisted — формат хранения сессии на диске. +type lkSessionDisk struct { + User LKUser `json:"user"` + ExpiresAt time.Time `json:"expires_at"` +} + +// load поднимает непротухшие сессии с диска при старте сервера. +func (s *lkSessions) load() { + raw, err := os.ReadFile(lkSessionsFile) + if err != nil { + return + } + var m map[string]lkSessionDisk + if json.Unmarshal(raw, &m) != nil { + return + } + now := time.Now() + for tok, v := range m { + if now.Before(v.ExpiresAt) { + s.data[tok] = lkSession{user: v.User, expiresAt: v.ExpiresAt} + } + } +} + +// save сбрасывает сессии на диск (вызывается под удержанным mu). +func (s *lkSessions) saveLocked() { + m := make(map[string]lkSessionDisk, len(s.data)) + for tok, v := range s.data { + m[tok] = lkSessionDisk{User: v.user, ExpiresAt: v.expiresAt} + } + raw, err := json.Marshal(m) + if err != nil { + return + } + tmp := lkSessionsFile + ".tmp" + if os.WriteFile(tmp, raw, 0o600) == nil { + _ = os.Rename(tmp, lkSessionsFile) + } +} + +func (s *lkSessions) create(u LKUser) string { + b := make([]byte, 24) + _, _ = rand.Read(b) + tok := hex.EncodeToString(b) + s.mu.Lock() + s.data[tok] = lkSession{user: u, expiresAt: time.Now().Add(lkSessionTTL)} + // подчистим протухшие + for k, v := range s.data { + if time.Now().After(v.expiresAt) { + delete(s.data, k) + } + } + s.saveLocked() + s.mu.Unlock() + return tok +} + +func (s *lkSessions) get(tok string) (LKUser, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.data[tok] + if !ok || time.Now().After(v.expiresAt) { + return LKUser{}, false + } + // Скользящий срок: пока клиент работает — сессия не истекает. + // Пишем на диск не чаще раза в 10 минут, чтобы не дёргать файл на каждый запрос. + if until := time.Until(v.expiresAt); until < lkSessionTTL-10*time.Minute { + v.expiresAt = time.Now().Add(lkSessionTTL) + s.data[tok] = v + s.saveLocked() + } + return v.user, true +} + +func (s *lkSessions) drop(tok string) { + s.mu.Lock() + delete(s.data, tok) + s.saveLocked() + s.mu.Unlock() +} + +// registerLKAPI вешает /api/lk/* — интерфейс для модуля личного кабинета. +func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { + sess := newLKSessions() + + // проверка ключа модуля + auth := func(w http.ResponseWriter, r *http.Request) bool { + key := strings.TrimSpace(rc.Snapshot().LK.APIKey) + if key == "" { + lkJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "API личного кабинета не настроен (нет ключа)"}) + return false + } + got := r.Header.Get("X-BJ-Key") + if subtle.ConstantTimeCompare([]byte(got), []byte(key)) != 1 { + lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "неверный ключ модуля"}) + return false + } + return true + } + // текущий клиент по session-токену + current := func(w http.ResponseWriter, r *http.Request) (LKUser, bool) { + tok := r.Header.Get("X-BJ-Session") + u, ok := sess.get(tok) + if !ok { + lkJSON(w, http.StatusUnauthorized, map[string]string{"error": "сессия истекла, войдите заново"}) + return LKUser{}, false + } + return u, true + } + + mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + lkJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "service": "bj-server", + "db_ready": lkDBReady(rc), + "server_time": time.Now().UTC().Format(time.RFC3339), + }) + }) + + mux.HandleFunc("/api/lk/login", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + if r.Method != http.MethodPost { + lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"}) + return + } + var in struct{ Login, Password string } + if json.NewDecoder(r.Body).Decode(&in) != nil { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) + return + } + u, err := lkAuth(r.Context(), rc, strings.TrimSpace(in.Login), in.Password) + if err != nil { + lkJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + return + } + lkJSON(w, http.StatusOK, map[string]any{ + "session": sess.create(*u), + "full_name": u.FullName, + "contract": u.Contract, + "is_admin": u.IsAdmin, + "ttl_sec": int(lkSessionTTL.Seconds()), + }) + }) + + mux.HandleFunc("/api/lk/logout", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + sess.drop(r.Header.Get("X-BJ-Session")) + lkJSON(w, http.StatusOK, map[string]bool{"ok": true}) + }) + + mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + fancy := rc.Snapshot().Fancy + // Сначала пробуем РЕАЛЬНЫЙ портфель из депозитарного учёта (BJ_DEPO_DATA) + // по номеру договора. Если такого договора там нет — берём данные из + // позиций модуля ЛК в PostgreSQL bj (тестовые клиенты). + source := "депозитарный учёт" + readOnly := true + var pos []ClientPosition + if dp, err := depoClientPositions(r.Context(), fancy, u.Contract); err == nil && len(dp) > 0 { + pos = depoPositionsAsClient(dp) + // Реальный депозитарный портфель — по умолчанию только просмотр. + // Подачу заявок разрешаем точечно тестовым клиентам (флаг can_transfer). + if u.CanTransfer { + readOnly = false + } + } else { + source = "позиции модуля ЛК" + readOnly = false + p2, err2 := lkClientPositions(r.Context(), rc, u.Contract) + if err2 != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить портфель: " + err2.Error()}) + return + } + pos = p2 + } + lkJSON(w, http.StatusOK, map[string]any{ + "contract": u.Contract, + "full_name": u.FullName, + "positions": pos, + "source": source, + "read_only": readOnly, + }) + }) + + mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + rows, err := lkClaimsByContract(r.Context(), rc, u.Contract, 100) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + lkJSON(w, http.StatusOK, map[string]any{"claims": rows}) + }) + + // --- Администратор ЛК: список клиентов и вход от их имени --- + mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + if !u.IsAdmin { + lkJSON(w, http.StatusForbidden, map[string]string{"error": "доступно только администратору"}) + return + } + fancy := rc.Snapshot().Fancy + list, err := lkListUsers(r.Context(), rc) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + // Плюс реальные клиенты депозитария: их 1300+, учётки ЛК им не заводим — + // админ ищет по номеру договора/ФИО и открывает кабинет на просмотр. + search := strings.TrimSpace(r.URL.Query().Get("search")) + depo, derr := depoListContracts(r.Context(), fancy, search, 100) + resp := map[string]any{"clients": list, "depo": depo, "search": search} + if derr != nil { + resp["depo_error"] = derr.Error() + } + lkJSON(w, http.StatusOK, resp) + }) + + mux.HandleFunc("/api/lk/admin/impersonate", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + if !u.IsAdmin { + lkJSON(w, http.StatusForbidden, map[string]string{"error": "доступно только администратору"}) + return + } + var in struct{ Login, Contract string } + if json.NewDecoder(r.Body).Decode(&in) != nil || + (strings.TrimSpace(in.Login) == "" && strings.TrimSpace(in.Contract) == "") { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указан клиент"}) + return + } + // Вход в кабинет реального клиента депозитария — учётки ЛК у него нет, + // сессия виртуальная и только на просмотр. + if c := strings.TrimSpace(in.Contract); c != "" { + full, ok := depoContractExists(r.Context(), rc.Snapshot().Fancy, c) + if !ok { + lkJSON(w, http.StatusNotFound, map[string]string{"error": "договор не найден в депозитарии: " + c}) + return + } + name := full + if i := strings.Index(full, " "); i > 0 { + name = strings.TrimSpace(full[i:]) + } + target := LKUser{ + Login: "depo:" + full, Contract: full, FullName: name, + Active: true, ImpersonatedBy: u.Login, + } + lkJSON(w, http.StatusOK, map[string]any{ + "session": sess.create(target), "full_name": name, + "contract": full, "as_client": full, "by_admin": u.Login, + }) + return + } + list, err := lkListUsers(r.Context(), rc) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + for _, c := range list { + if c.Login == in.Login { + target := c + target.ImpersonatedBy = u.Login // помечаем: вошёл админ + lkJSON(w, http.StatusOK, map[string]any{ + "session": sess.create(target), "full_name": c.FullName, + "contract": c.Contract, "as_client": c.Login, "by_admin": u.Login, + }) + return + } + } + lkJSON(w, http.StatusNotFound, map[string]string{"error": "клиент не найден"}) + }) + + mux.HandleFunc("/api/lk/transfer", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + if r.Method != http.MethodPost { + lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"}) + return + } + var in LKTransferRequest + if json.NewDecoder(r.Body).Decode(&in) != nil { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": "некорректный JSON"}) + return + } + res, err := svc.CreateLKTransfer(r.Context(), rc, u, in) + if err != nil { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + lkJSON(w, http.StatusOK, res) + }) +} + +func lkJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/lkgateway/lkconnector.go b/internal/lkgateway/lkconnector.go new file mode 100644 index 0000000..099d2a6 --- /dev/null +++ b/internal/lkgateway/lkconnector.go @@ -0,0 +1,612 @@ +package lkgateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +// settlementPlaceINN возвращает ИНН места расчётов по коду площадки из ЛК: +// spb → СПБ Банк (7831000034), иначе (nrd/пусто) → НРД (7702165310). Это два +// единственных места расчётов из справочника M2M. +func settlementPlaceINN(place string) string { + if strings.EqualFold(strings.TrimSpace(place), "spb") { + return "7831000034" + } + return "7702165310" +} + +// lkClaimInvestorKey — стабильный ключ инвестора для lkInvestorUUID: ЛК user_id +// (или id заявки, если пользователь не задан). Один инвестор → один UUID сделки. +func lkClaimInvestorKey(cl map[string]any) string { + if u := str(cl["user_id"]); u != "" && u != "0" { + return "esia-user:" + u + } + return "esia-claim:" + str(cl["id"]) +} + +// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по +// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт. +const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json" + +var ( + lkLinksMu sync.Mutex + lkLinks = map[string]int{} + lkLoaded bool +) + +func lkLinkGUID(guid string, _ LKEndpoint, claimID int) { + lkLinksMu.Lock() + lkEnsureLinksLoaded() + lkLinks[guid] = claimID + if raw, err := json.Marshal(lkLinks); err == nil { + _ = os.WriteFile(lkLinksFile, raw, 0o600) + } + lkLinksMu.Unlock() + // Персистентная запись происхождения — для отображения «путь из ЛК» в + // карточке. В отличие от lkLinks, НЕ удаляется после финального статуса. + lkRecordOrigin(guid, claimID) +} +func lkEnsureLinksLoaded() { + if lkLoaded { + return + } + lkLoaded = true + if raw, err := os.ReadFile(lkLinksFile); err == nil { + _ = json.Unmarshal(raw, &lkLinks) + } +} + +// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI +// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся. +const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json" + +var ( + lkOriginMu sync.Mutex + lkOrigin = map[string]int{} + lkOriginLoaded bool +) + +func lkEnsureOriginLoaded() { + if lkOriginLoaded { + return + } + lkOriginLoaded = true + if raw, err := os.ReadFile(lkOriginFile); err == nil { + _ = json.Unmarshal(raw, &lkOrigin) + return + } + // Бэкофилл: если постоянного файла ещё нет — засеваем из активных связок + // (уже отправленные, но не финализированные ЛК-заявки тоже покажут путь). + if raw, err := os.ReadFile(lkLinksFile); err == nil { + _ = json.Unmarshal(raw, &lkOrigin) + } +} +func lkRecordOrigin(guid string, claimID int) { + lkOriginMu.Lock() + defer lkOriginMu.Unlock() + lkEnsureOriginLoaded() + lkOrigin[guid] = claimID + if raw, err := json.Marshal(lkOrigin); err == nil { + _ = os.WriteFile(lkOriginFile, raw, 0o600) + } +} + +// LKClaimIDByGUID возвращает id заявки ЛК по guid отправленного запроса и true, +// если заявка происходит из ЛК ЕСИА Финанс (для отображения пути в карточке). +func LKClaimIDByGUID(guid string) (int, bool) { + lkOriginMu.Lock() + defer lkOriginMu.Unlock() + lkEnsureOriginLoaded() + id, ok := lkOrigin[guid] + return id, ok +} + +// lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей +// переводов при повторных проходах поллера). Переживает рестарт. +const lkSentFile = "/var/lib/bj/.bj/lk-sent.json" + +var ( + lkSentMu sync.Mutex + lkSent = map[int]bool{} + lkSentLoaded bool +) + +func lkEnsureSentLoaded() { + if lkSentLoaded { + return + } + lkSentLoaded = true + if raw, err := os.ReadFile(lkSentFile); err == nil { + var ids []int + if json.Unmarshal(raw, &ids) == nil { + for _, id := range ids { + lkSent[id] = true + } + } + } +} +func lkAlreadySent(id int) bool { + lkSentMu.Lock() + defer lkSentMu.Unlock() + lkEnsureSentLoaded() + return lkSent[id] +} +func lkMarkSent(id int) { + lkSentMu.Lock() + defer lkSentMu.Unlock() + lkEnsureSentLoaded() + lkSent[id] = true + ids := make([]int, 0, len(lkSent)) + for k := range lkSent { + ids = append(ids, k) + } + if raw, err := json.Marshal(ids); err == nil { + _ = os.WriteFile(lkSentFile, raw, 0o600) + } +} + +// OnLKOutcome вызывается nsdpoller по ответу НРД: PATCH статуса заявки в ЛК. +// status: "done" (исполнена) или "rejected" (отклонена). reason — в client_notation. +func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) { + lkLinksMu.Lock() + lkEnsureLinksLoaded() + id, ok := lkLinks[guid] + if ok { + delete(lkLinks, guid) + if raw, err := json.Marshal(lkLinks); err == nil { + _ = os.WriteFile(lkLinksFile, raw, 0o600) + } + } + lkLinksMu.Unlock() + if !ok { + return // не заявка из ЛК-коннектора + } + ep, okEp := lkActiveEndpoint(rc) + if !okEp { + return + } + fs := "done" + if status != "done" { + fs = "rejected" + } + body, _ := json.Marshal(map[string]any{"claim": map[string]any{"status": fs, "client_notation": reason}}) + pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), body) + title, detail := "Статус в ЛК: «Исполнена»", reason + if fs == "rejected" { + title = "Статус в ЛК: «Отклонена»" + } + if pc >= 400 || pc == 0 { + title, detail = title+" — не записан", fmt.Sprintf("HTTP %d %s", pc, pb) + } + globalLKFlows.stepIfExists(guid, "response", title, detail) +} + +// Коннектор к API ЕСИА Финанс (back_office). bj — КЛИЕНТ: забирает новые заявки +// depo_m2m, строит M2MTransferRequest, отправляет в НРД (робот на тесте) и +// возвращает статус через PATCH. Структуры — по swagger_backoffice_4-*.yaml. +// +// Поток (ручной триггер «Забрать заявки из ЛК» / позже — фоновый поллер): +// GET /claims?kind=depo_m2m&status=in_queue — новые заявки +// GET /users/{user_id} — ФИО/ДУЛ инвестора +// → маппинг → BuildRequest → SendBuiltRequest (в НРД) +// PATCH /claims/{id} {"claim":{"status":"in_process"}} — «В работе» +// Ответ НРД (Decision/ERROR) ловит nsdpoller и через OnLKOutcome PATCH-ит +// «Исполнена»/«Отклонена». + +// lkHTTP — общий клиент к API ЛК с Basic активного окружения. +func lkActiveEndpoint(rc *RuntimeConfig) (LKEndpoint, bool) { + s := rc.Snapshot().LK + ep := s.Test + if s.ActiveEnv == "prod" { + ep = s.Prod + } + return ep, ep.BaseURL != "" && ep.Login != "" +} + +func lkGet(ctx context.Context, ep LKEndpoint, path string) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, lkAPIBase(ep.BaseURL)+path, nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(ep.Login, ep.Password) + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, b, nil +} + +func lkPatch(ctx context.Context, ep LKEndpoint, path string, body []byte) (int, string) { + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, lkAPIBase(ep.BaseURL)+path, strings.NewReader(string(body))) + if err != nil { + return 0, err.Error() + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(ep.Login, ep.Password) + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return 0, err.Error() + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return resp.StatusCode, strings.TrimSpace(string(b)) +} + +// LKPullResult — итог одного прохода «забрать заявки». +type LKPullResult struct { + Fetched int + Sent int // отправлено в НРД (или имитатору) + Rejected int // отклонено сверкой до отправки + Emulated bool + Errors []string +} + +// pollLKClaims — фоновый опрос ЛК ЕСИА Финанс раз в минуту. Время ответов в +// сервисе регламентировано, поэтому чаще опрашивать смысла нет. Ничего не +// делает, если активное окружение ЛК не настроено (адрес/логин пустые). +func (s *Server) pollLKClaims(ctx context.Context) { + const interval = time.Minute + ticker := time.NewTicker(interval) + defer ticker.Stop() + log.Printf("lk-gateway: поллер заявок ЛК ЕСИА Финанс запущен (интервал %s)", interval) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + healthBeat("poll-lk") + if _, ok := lkActiveEndpoint(s.rc); !ok { + continue // ЛК не настроен — тихо ждём + } + cctx, cancel := context.WithTimeout(ctx, 50*time.Second) + res, err := PullLKClaims(cctx, s.rc, s.svc) + cancel() + if err != nil { + log.Printf("lk-gateway: поллер ЛК: %v", err) + continue + } + if res.Fetched > 0 || len(res.Errors) > 0 { + log.Printf("lk-gateway: поллер ЛК: получено %d, отправлено %d, отклонено %d, ошибок %d (имитация НРД: %v)", + res.Fetched, res.Sent, res.Rejected, len(res.Errors), res.Emulated) + for i, e := range res.Errors { + if i >= 5 { + log.Printf("lk-gateway: поллер ЛК: …ещё %d ошибок", len(res.Errors)-5) + break + } + log.Printf("lk-gateway: поллер ЛК: ошибка %s", e) + } + } + } + } +} + +// PullLKClaims забирает новые depo_m2m заявки из ЛК и отправляет в НРД. +func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullResult, error) { + var res LKPullResult + ep, ok := lkActiveEndpoint(rc) + if !ok { + return res, fmt.Errorf("ЛК: не настроено активное окружение (адрес/логин)") + } + res.Emulated, _ = lkEmulateNSD(rc) + // ВАЖНО: back_office фильтрует ТОЛЬКО по filter[...] (Rails strong params); + // плоские kind=/status= молча игнорируются, а список отдаётся id по + // возрастанию (старейшие) — свежая заявка на первую страницу не попадёт. + // order[]=-id — новые первыми. Статус фильтруем у себя: на тесте подписанная + // заявка встаёт «В очереди» (in_queue), в бою сразу «В работе» (in_process). + code, body, err := lkGet(ctx, ep, "/claims?filter%5Bkind%5D=depo_m2m&order%5B%5D=-id&per_page=50") + if err != nil { + return res, fmt.Errorf("GET /claims: %w", err) + } + if code != 200 { + return res, fmt.Errorf("GET /claims → HTTP %d: %s", code, snippet(body)) + } + var list struct { + Claims []map[string]any `json:"claims"` + } + if json.Unmarshal(body, &list) != nil { + return res, fmt.Errorf("разбор списка заявок не удался") + } + // ВАЖНО: API back_office может игнорировать query-фильтры — фильтруем строго на + // нашей стороне: depo_m2m в статусе in_queue/in_process, которые bj ещё не + // обрабатывал (после обработки bj сам ставит «В работе» — такие пропускаем молча). + for _, c := range list.Claims { + if st := str(c["status"]); str(c["kind"]) != "depo_m2m" || (st != "in_queue" && st != "in_process") { + continue // чужой вид/статус — не наша заявка + } + id := toInt(c["id"]) + if id == 0 || lkAlreadySent(id) { + continue + } + res.Fetched++ + outcome, err := pullOneLKClaim(ctx, rc, svc, ep, id) + if err != nil { + res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err)) + continue + } + if outcome == "rejected" { + res.Rejected++ + } else { + res.Sent++ + } + } + return res, nil +} + +// pullOneLKClaim: полная заявка → инвестор → M2M → НРД (или имитатор со сверкой DEPO, +// lkemulate.go) → PATCH «В работе». Возвращает "sent" или "rejected". +func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKEndpoint, id int) (string, error) { + code, body, err := lkGet(ctx, ep, "/claims/"+strconv.Itoa(id)) + if err != nil || code != 200 { + return "", fmt.Errorf("GET /claims/%d → %d %v", id, code, err) + } + var wrap struct { + Claim map[string]any `json:"claim"` + } + if json.Unmarshal(body, &wrap) != nil || wrap.Claim == nil { + return "", fmt.Errorf("разбор заявки") + } + cl := wrap.Claim + data, _ := cl["data"].(map[string]any) + if data == nil { + return "", fmt.Errorf("нет data в заявке") + } + claimReq, err := lkClaimToRequest(ctx, rc, ep, cl, data) + if err != nil { + return "", err + } + // Регистрируем клиента в справочнике (как для ЛК-подачи). + if ss, ok := svc.store.(*SeedStore); ok { + nsd := rc.Snapshot().NSD + settle := m2mcore.DepoAccount{ + DeponentCode: nsd.DeponentCode, + AccountID: m2m.AccountID(nsd.AccountID), + SectionID: nsd.SectionID, + // SettlementRequisites.INN — это ИНН МЕСТА РАСЧЁТОВ (центральный + // депозитарий), а НЕ наш собственный ИНН: НРД 7702165310 либо СПБ + // Банк 7831000034 по place_of_payment. Иначе M2M19 «недопустимое + // место расчётов» (наш ИНН отсутствует в списке мест расчётов). + DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(str(data["contragent_place_of_payment"]))), + } + ss.EnsureLKClient(lkUserFromClaim(cl, claimReq), nil, settle) + } + if emu, _ := lkEmulateNSD(rc); emu { + return lkProcessEmulated(ctx, rc, svc, ep, id, claimReq) + } + for _, sec := range claimReq.Securities { + if isin := sec.SecurityDetails.ISIN; isin != "" && !isRussianISIN(isin) { + lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Code: "M2M01", Text: fmt.Sprintf("иностранная бумага %s — перевод M2M недоступен", isin)}) + return "rejected", nil + } + } + req, err := svc.BuildRequest(ctx, claimReq) + if err != nil { + if strings.Contains(err.Error(), "невалиден") { // данные не проходят формат НРД — повтор не поможет + lkRejectInvalid(ctx, rc, ep, id, &lkDataError{Text: "данные заявки не прошли проверку формата НРД: " + err.Error()}) + return "rejected", nil + } + return "", fmt.Errorf("BuildRequest: %w", err) + } + // At-most-once: если заявка уже отправлялась в НРД — не шлём повторно + // (иначе поллер каждую минуту плодил бы дубли переводов при ошибке PATCH). + if lkAlreadySent(id) { + return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id) + } + if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil { + // Отправка не удалась (напр. до реального Send в НРД) — НЕ помечаем + // отправленной, чтобы можно было повторить после устранения причины. + return "", fmt.Errorf("отправка в НРД: %w", err) + } + // Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль. + lkMarkSent(id) + // Связка guid → id заявки ЛК для обратного PATCH статуса. + lkLinkGUID(string(req.Header.GUID), ep, id) + // «В работе». + pc, pb := lkPatch(ctx, ep, "/claims/"+strconv.Itoa(id), []byte(`{"claim":{"status":"in_process"}}`)) + if pc >= 400 { + return "", fmt.Errorf("отправлено в НРД, но PATCH статуса не прошёл: HTTP %d %s", pc, pb) + } + return "sent", nil +} + +// lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest. +func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) { + var req CreateClaimRequest + nsd := rc.Snapshot().NSD + + // Бумага (paper вложен; paper_count плоско). + paper, _ := data["paper"].(map[string]any) + // Данные заявки здесь не браковать: заявка должна попасть в журнал и пройти проверку + // шлюза, которая и ответит отказом (lkemulate.go / lkflow.go). + isin := strings.ToUpper(strings.TrimSpace(str(paper["isin"]))) + var qty Quantity + pc := toFloat(data["paper_count"]) + switch { + case pc <= 0: + qty.Fractional = "0" // пакет соберётся без проверки формата, отказ даст сверка + case pc == float64(uint64(pc)): + qty.Whole = uint64(pc) + default: + qty.Fractional = strconv.FormatFloat(pc, 'f', -1, 64) + } + sd := SecurityDetails{} + code := isin + if isin != "" { + sd.ISIN = isin + } else { + code = str(paper["registration_number"]) + sd.SecurityInfo = &SecurityInfo{IdentificationDetails: IdentificationDetails{RegNumber: str(paper["registration_number"])}} + } + sec := ClaimSec{ + SecurityCode: code, + SecurityDetails: sd, + Quantity: qty, + SettlementAccounts: []SettlementAccount{{ + SettlementRequisitesINN: transferringINN(rc.Snapshot()), + SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID}, + }}, + } + + // Инвестор — из GET /users/{user_id}. ID инвестора хранится в uuid-колонке + // журнала сделок, поэтому строим детерминированный UUIDv5 из ЛК-идентификатора + // (один инвестор ЛК → один UUID во всех его заявках). + inv := Investor{ID: lkInvestorUUID(lkClaimInvestorKey(cl))} + if uid := toInt(cl["user_id"]); uid != 0 { + if _, ub, err := lkGet(ctx, ep, "/users/"+strconv.Itoa(uid)); err == nil { + var uw map[string]any + if json.Unmarshal(ub, &uw) == nil { + u := pickMap(uw, "user", "data") + if u == nil { + u = uw + } + inv.LastName = str(u["surname"]) + inv.FirstName = str(u["name"]) + inv.MiddleName = str(u["patronymic"]) + inv.BirthDate = firstNonEmpty(u, "birthday", "birth_date", "date_of_birth") + inv.Document = Document{ + DocumentType: mapDocType(str(u["document_type"])), + // На стенде поля паспорта: pass_series / pass_number. + Series: firstNonEmpty(u, "pass_series", "document_series", "series", "passport_series"), + Number: firstNonEmpty(u, "pass_number", "document_number", "number", "passport_number"), + } + } + } + } + + cost := CostInfo{No: &struct{}{}} + if b, _ := data["expense_information"].(bool); b { + cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}} + } + req = CreateClaimRequest{ + Investor: inv, + TransferringDepositoryINN: transferringINN(rc.Snapshot()), + ReceivingDepositoryINN: str(data["contragent_inn"]), + CostInfo: cost, + Securities: []ClaimSec{sec}, + SignatureFormat: "ESIA", + ClientAccount: strings.TrimSpace(str(data["account"])), + } + // ТЕСТ-МАРШРУТ К РОБОТУ НРД. Если наименование контрагента содержит маркер + // «РОБОТ <сценарий>» (1111/2001/2002/3333) — заявка адресуется роботу + // MC0012500000, а серия/номер ДУЛ переопределяются на сценарий (робот + // читает сценарий из DocumentSeries). Прод-заявки этим маркером не + // затрагиваются. Позволяет прогонять все сценарии через путь ЛК. + if sc := lkDetectRobotScenario(str(data["contragent_name"])); sc != "" { + doc := Document{DocumentType: "21", Series: sc, Number: robotDocumentNumbers[sc]} + req.Investor.Document = doc + req.InvestorDocumentOverride = &doc + req.ReceiverCodeOverride = NSDRobotCode + // Робот проверяет реквизиты по своему эталонному набору: конкретные ИНН + // передающего/зачисляющего депозитариев и бумаги с кодом НРД (не ISIN), + // иначе МОСТ отклоняет (M2M18/M2M30). Подставляем ровно как в эталонном + // buildRobotClaimRequest — ЛК-заявка служит только триггером сценария. + req.TransferringDepositoryINN = "7703807489" + req.ReceivingDepositoryINN = "7722061076" + req.CostInfo = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}} + req.Securities = buildRobotSecurities(nsd.DeponentCode, nsd.AccountID, nsd.SectionID, 0) + } + return req, nil +} + +// lkDetectRobotScenario извлекает код сценария робота из наименования +// контрагента ЛК-заявки (маркер вида «РОБОТ 2001» / «robot-1111»). Возвращает +// "" если маркера нет — тогда заявка идёт обычным путём. +func lkDetectRobotScenario(contragentName string) string { + n := strings.ToUpper(contragentName) + if !strings.Contains(n, "РОБОТ") && !strings.Contains(n, "ROBOT") { + return "" + } + for _, sc := range []string{"1111", "2001", "2002", "3333"} { + if strings.Contains(n, sc) { + return sc + } + } + return "" +} + +// --- вспомогательное --- + +func lkUserFromClaim(cl map[string]any, req CreateClaimRequest) LKUser { + return LKUser{ + Login: "lk:" + str(cl["id"]), Contract: req.Investor.ID, + LastName: req.Investor.LastName, FirstName: req.Investor.FirstName, MiddleName: req.Investor.MiddleName, + DocType: req.Investor.Document.DocumentType, DocSeries: req.Investor.Document.Series, DocNumber: req.Investor.Document.Number, + Active: true, + } +} + +func mapDocType(t string) string { + if t == "passport" || t == "" { + return "21" // паспорт РФ по справочнику НРД + } + return t +} + +func firstNonEmpty(m map[string]any, keys ...string) string { + for _, k := range keys { + if v := str(m[k]); v != "" { + return v + } + } + return "" +} +func pickMap(m map[string]any, keys ...string) map[string]any { + for _, k := range keys { + if v, ok := m[k].(map[string]any); ok { + return v + } + } + return nil +} +func str(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} +func toInt(v any) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case string: + i, _ := strconv.Atoi(n) + return i + } + return 0 +} +func toFloat(v any) float64 { + switch n := v.(type) { + case float64: + return n + case string: + f, _ := strconv.ParseFloat(strings.ReplaceAll(n, ",", "."), 64) + return f + } + return 0 +} +func snippet(b []byte) string { + if len(b) > 200 { + return string(b[:200]) + "…" + } + return string(b) +} diff --git a/internal/lkgateway/lkdata_test.go b/internal/lkgateway/lkdata_test.go new file mode 100644 index 0000000..84bae18 --- /dev/null +++ b/internal/lkgateway/lkdata_test.go @@ -0,0 +1,40 @@ +package lkgateway + +import ( + "context" + "path/filepath" + "testing" +) + +// Данные заявки ЛК на входе не бракуются: заявка забирается и уходит в журнал, решает +// проверка шлюза (сверка с DEPO) и формат НРД. +func TestLKClaimAcceptedForGatewayCheck(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + data map[string]any + }{ + {"ISIN из ЛК-стенда (8 символов)", map[string]any{"paper": map[string]any{"name": "СБЕР", "isin": "RU123321", "registration_number": "321123"}, "paper_count": 1.0}}, + {"ISIN длиннее 12", map[string]any{"paper": map[string]any{"isin": "RU0009029540X"}, "paper_count": 1.0}}, + {"иностранная бумага", map[string]any{"paper": map[string]any{"isin": "US0378331005"}, "paper_count": 1.0}}, + {"нет бумаги", map[string]any{"paper": map[string]any{"name": "СБЕР"}, "paper_count": 1.0}}, + {"нет количества", map[string]any{"paper": map[string]any{"isin": "RU0009029540"}, "paper_count": 0.0}}, + } + for _, c := range cases { + req, err := lkClaimToRequest(context.Background(), rc, LKEndpoint{}, map[string]any{"id": 1.0}, c.data) + if err != nil { + t.Errorf("%s: заявка должна приниматься в разбор, получили %v", c.name, err) + continue + } + if len(req.Securities) != 1 { + t.Errorf("%s: бумаг %d, ожидали 1", c.name, len(req.Securities)) + continue + } + if _, err := dtoToClaim(req); err != nil { + t.Errorf("%s: пакет должен собираться без проверки формата, dtoToClaim: %v", c.name, err) + } + } +} diff --git a/internal/lkgateway/lkemulate.go b/internal/lkgateway/lkemulate.go new file mode 100644 index 0000000..991387b --- /dev/null +++ b/internal/lkgateway/lkemulate.go @@ -0,0 +1,289 @@ +package lkgateway + +// Имитация НРД для заявок ЛК ЕСИА Финанс. Заявка проходит весь путь, как в бою: +// +// ЛК (depo_m2m) → журнал исходящих заявок bj → проверка шлюза (сверка списания с DEPO) +// отказ → сделка «Отклонена» → отказ в Fancy (DEPO) → ответ Fancy → ЛК «Отклонена» (lkflow.go) +// принять → блокировка в QUIK МНП → DEPO (BJ_SENT) → «отправка» в имитатор НРД +// → через lkEmuDecisionDelay решение контрагента → тот же обработчик, что у +// настоящего M2MTransferDecision (сделка, ЛК «Исполнена», QUIK, DEPO) +// +// В НРД ничего не уходит. Включается флагом LK.EmulateNSD и принудительно, если активен +// боевой ЛК, а профиль ИШ тестовый: настоящие заявки клиентов в тестовый НРД не шлём. + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock" +) + +const lkEmuDecisionDelay = 30 * time.Second + +// lkEmuNote дописывается к статусу в ЛК, чтобы было видно, что НРД не участвовал. +const lkEmuNote = " (тестовый режим: в НРД не отправлялось)" + +// lkEmuSender — имитатор НРД для заявок ЛК; создаётся в Server.Run. +var lkEmuSender *mock.Sender + +// lkEmulateNSD — идут ли заявки ЛК через имитатор НРД и почему. +func lkEmulateNSD(rc *RuntimeConfig) (bool, string) { + s := rc.Snapshot() + if s.LK.EmulateNSD { + return true, "включена в настройках" + } + if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") { + return true, "боевой ЛК при тестовом профиле ИШ " + s.NSD.Profile + } + return false, "" +} + +// lkProcessEmulated — путь заявки ЛК без отправки в НРД. Возвращает "rejected" или +// "sent"; ошибка — заявку нужно повторить на следующем проходе (DEPO недоступна и т.п.). +func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKEndpoint, id int, claimReq CreateClaimRequest) (string, error) { + if lkEmuSender == nil { + return "", fmt.Errorf("имитатор НРД не запущен") + } + inv := claimReq.Investor + + // Формат полей заявки не решает, берём ли её: решает проверка шлюза. + var secs []depoOutSec + acc, section := "", "" + for _, sec := range claimReq.Securities { + qty := sec.Quantity.Fractional + if qty == "" { + qty = strconv.FormatUint(sec.Quantity.Whole, 10) + } + secs = append(secs, depoOutSec{ISIN: sec.SecurityDetails.ISIN, Code: sec.SecurityCode, Qty: qty}) + if len(sec.SettlementAccounts) > 0 && acc == "" { + loc := sec.SettlementAccounts[0].SettlementLocation + acc, section = loc.AccountID, loc.SectionID + } + } + res := runDepoCheckOutAccount(inv.LastName, inv.FirstName, inv.MiddleName, inv.Document.Series, inv.Document.Number, inv.BirthDate, secs, acc, section, claimReq.ClientAccount) + if !res.Ran { + return "", fmt.Errorf("сверка с DEPO не выполнена: %s", res.Err) + } + // Пакет собираем и при некорректных данных: заявка должна попасть в журнал и в Fancy. + req, err := svc.BuildRequestUnchecked(ctx, claimReq) + if err != nil { + return "", fmt.Errorf("сборка пакета: %w", err) + } + guid := string(req.Header.GUID) + log.Printf("lk-gateway: ЛК #%d (%s): сверка с DEPO — %s (инвестор %s, счёт %s)", id, shortGUID(guid), res.Summary, pick(res.Investor, "не найден"), pick(res.Account, "—")) + globalLKFlows.step(guid, id, "db", "Заявка из ЛК ЕСИА Финанс", fmt.Sprintf("Забрана коннектором bj из личного кабинета (заявка ЛК #%d).", id)) + + code, short, reason := "", "", "" + if res.Recommend != "confirm" { + code, short, reason = lkDepoRejectText(res) + } else if verr := req.Validate(); verr != nil { + short, reason = "Отказ: данные заявки не прошли проверку", "Отказ: данные заявки не прошли проверку формата НРД — "+verr.Error() + } + if reason != "" { + globalLKFlows.step(guid, id, "reject", "Проверка шлюза bj: отказ", reason) + lkGatewayReject(ctx, rc, svc, id, inv.ID, req, code, short, reason) + log.Printf("lk-gateway: ЛК #%d (%s): отказ шлюза, передан в Fancy — %s", id, shortGUID(guid), reason) + return "rejected", nil + } + globalLKFlows.step(guid, id, "crane", "Проверка шлюза bj: можно исполнить", + fmt.Sprintf("Сверка с DEPO: %s (инвестор %s, счёт %s).", res.Summary, pick(res.Investor, "—"), pick(res.Account, "—"))) + + if _, err := svc.SendBuiltRequestVia(ctx, lkEmuSender, req, inv.ID, nil); err != nil { + if strings.Contains(err.Error(), "QUIK") { + reason := "Отказ: не удалось заблокировать бумаги клиента — " + err.Error() + globalLKFlows.step(guid, id, "reject", "Блокировка в QUIK не удалась", err.Error()) + lkGatewayReject(ctx, rc, svc, id, inv.ID, req, "QUIK", "Отказ: не удалось заблокировать ценные бумаги", reason) + return "rejected", nil + } + return "", fmt.Errorf("имитация отправки: %w", err) + } + lkMarkSent(id) + lkLinkGUID(guid, ep, id) + globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" }) + globalLKFlows.step(guid, id, "gateway", "Бумаги заблокированы, заявка передана имитатору НРД", + lkQuikSummary(guid)+fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay)) + if pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), []byte(`{"claim":{"status":"in_process"}}`)); pc >= 400 { + log.Printf("lk-gateway: ЛК #%d: PATCH «В работе» не прошёл: HTTP %d %s", id, pc, pb) + } + log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelay) + return "sent", nil +} + +// lkQuikSummary — что сделал QUIK МНП по переводу (для шага пути). +func lkQuikSummary(guid string) string { + links := globalQuikLinks.ForGUID(guid) + if len(links) == 0 { + return "QUIK: блокировка не выполнялась (цикл QUIK выключен или клиент не найден в DEPO)." + } + var parts []string + for _, l := range links { + p := fmt.Sprintf("%s × %v — %s", l.ISIN, l.Qty, l.StateRu()) + if l.InstrNo != "" { + p += ", поручение №" + l.InstrNo + } + if l.Err != "" { + p += ": " + l.Err + } + parts = append(parts, p) + } + return "QUIK: " + strings.Join(parts, "; ") + "." +} + +// lkDepoRejectText — код отказа по результату сверки: short — короткий текст для клиента в ЛК +// («Отказ по результатам проверки депозитария: <причина> (код)»), text — с подробностями +// проверок для журнала bj и Fancy. +func lkDepoRejectText(res depoResult) (code, short, text string) { + var codes, labels, details []string + for _, s := range res.Secs { + for _, c := range s.Checks { + if c.OK { + continue + } + if c.Code != "" && !containsStr(codes, c.Code) { + codes = append(codes, c.Code) + labels = append(labels, m2mCodeLabel(c.Code)) + } + details = append(details, c.Name+": "+c.Detail) + } + } + if len(codes) > 0 { + code = codes[0] + } + short = "Отказ по результатам проверки депозитария" + if len(labels) > 0 { + short += ": " + labels[0] + } + text = "Отказ по результатам проверки депозитария" + if len(labels) > 0 { + text += ": " + strings.Join(labels, "; ") + } + if len(details) > 0 { + text += ". Причина: " + strings.Join(details, "; ") + } + if r := []rune(text); len(r) > 1000 { + text = string(r[:1000]) + "…" + } + return code, short, text +} + +func containsStr(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// lkPatchRejected — заявка ЛК «Отклонена» с причиной в client_notation; помечается +// обработанной, чтобы поллер не брал её повторно. Возвращает HTTP-код и ответ ЛК. +func lkPatchRejected(ctx context.Context, ep LKEndpoint, id int, reason string) (int, string) { + lkMarkSent(id) + body, _ := json.Marshal(map[string]any{"claim": map[string]any{"status": "rejected", "client_notation": reason}}) + pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), body) + if pc >= 400 || pc == 0 { + log.Printf("lk-gateway: ЛК #%d: PATCH «Отклонена» не прошёл: HTTP %d %s", id, pc, pb) + } else { + log.Printf("lk-gateway: ЛК #%d: отклонена — %s", id, reason) + } + return pc, pb +} + +// lkDataError — заявку ЛК нельзя отправлять из-за её данных: повтор не поможет, отказываем. +type lkDataError struct{ Code, Text string } + +func (e *lkDataError) Error() string { return e.Text } + +// lkRejectInvalid — отказ по данным заявки в реальном режиме (без имитации, до отправки в НРД). +func lkRejectInvalid(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, id int, de *lkDataError) { + reason := "Отказ: заявка не может быть исполнена" + if de.Code != "" { + reason = "Отказ: " + m2mCodeLabel(de.Code) + } + log.Printf("lk-gateway: ЛК #%d: отказ по данным заявки — %s (%s)", id, de.Text, de.Code) + if pc, pb := lkPatchRejected(ctx, ep, id, reason); pc >= 400 || pc == 0 { + raiseSystemAlert(rc, "ЛК: не удалось отклонить заявку", fmt.Sprintf("Заявка ЛК #%d отклонена (%s), но статус в ЛК не изменён: HTTP %d %s", id, reason, pc, pb)) + } +} + +// consumeEmulatedDecisions — решения контрагента от имитатора НРД идут тем же путём, +// что настоящий M2MTransferDecision из ИШ. +func (s *Server) consumeEmulatedDecisions(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case d := <-lkEmuSender.Decisions(): + if d == nil { + continue + } + guid := string(d.Header.GUID) + log.Printf("lk-gateway: имитатор НРД: решение контрагента по %s", shortGUID(guid)) + conf, rej := 0, 0 + for _, sec := range d.Data.Securities { + if sec.TransferDecision.Confirmation != nil { + conf++ + } else { + rej++ + } + } + globalLKFlows.stepIfExists(guid, "response", "Решение контрагента (имитатор НРД)", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej)) + s.applyDecisionEffects(ctx, d, "имитатор НРД", lkEmuNote) + } + } +} + +// applyDecisionEffects — M2MTransferDecision контрагента: сделка, статус в таблице +// обмена Fancy и в ЛК, снятие блокировок QUIK по отказанным бумагам, событие DEPO. +// note дописывается к статусу в ЛК. +func (s *Server) applyDecisionEffects(ctx context.Context, decision *m2m.M2MTransferDecision, source, note string) { + if err := s.svc.ApplyDecision(ctx, decision); err != nil { + // Детерминированные ошибки (сделка уже в терминальном статусе — напр. + // повторный/встречный Decision, «недопустимый переход FSM») повтором + // не лечатся. Логируем и помечаем обработанным, иначе поллер зациклится. + log.Printf("lk-gateway: %s: Decision GUID=%s не применён (пропуск): %v", source, decision.Header.GUID, err) + return + } + // Разбираем исход по бумагам: сколько принято/отклонено и с какими кодами. + conf, rej := 0, 0 + var rejCodes []string + for _, sec := range decision.Data.Securities { + if sec.TransferDecision.Confirmation != nil { + conf++ + } else if sec.TransferDecision.Rejection != nil { + rej++ + rejCodes = append(rejCodes, sec.TransferDecision.Rejection.Codes...) + } + } + lkStatus, lkReason, fancyStatus, fancyVerdict := "done", "Перевод исполнен", "done", "approved" + switch { + case conf == 0 && rej > 0: // полный отказ принимающей стороны + lkStatus, fancyStatus, fancyVerdict = "rejected", "declined", "rejected" + lkReason = "Отказ принимающей стороны: " + rejectCodesLabel(rejCodes) + case rej > 0: // частичный приём + lkReason = fmt.Sprintf("Частично исполнено: принято %d, отклонено %d (%s)", conf, rej, rejectCodesLabel(rejCodes)) + default: // всё принято + lkReason = "Перевод исполнен" + } + // Итог для заявки модуля ЛК (bj-lk), если перевод оттуда. + if e := lkFinalizeByGUID(ctx, s.rc, string(decision.Header.GUID), fancyStatus, fancyVerdict, lkReason); e != nil { + log.Printf("lk-gateway: %s: finalize(%s) GUID=%s: %v", source, fancyStatus, decision.Header.GUID, e) + } + _ = note // клиенту в ЛК — только короткий итог, подробности в журнале bj + OnLKOutcome(ctx, s.rc, string(decision.Header.GUID), lkStatus, lkReason) + quikOnDecision(ctx, s.rc, s.svc, decision) + go depoOnCounterpartyDecision(s.rc, decision) +} + +func newLKEmuSender() *mock.Sender { + cfg := mock.DefaultConfig() + cfg.DecisionDelay = lkEmuDecisionDelay + cfg.NSDSenderCode = "MC0010300000" + return mock.NewSender(cfg) +} diff --git a/internal/lkgateway/lkemulate_test.go b/internal/lkgateway/lkemulate_test.go new file mode 100644 index 0000000..7095abc --- /dev/null +++ b/internal/lkgateway/lkemulate_test.go @@ -0,0 +1,61 @@ +package lkgateway + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestLKDepoRejectText(t *testing.T) { + res := depoResult{Ran: true, Recommend: "reject", Secs: []depoSecResult{{ + Security: "RU0009029540", + Checks: []depoCheck{ + {Name: "Российский выпуск (RU-ISIN)", OK: true, Code: "M2M01"}, + {Name: "Остаток ≥ количеству (98070.04)", OK: false, Code: "QTY", Detail: "на торговом счёте ВТНКЦ00000001: 0 шт., списываем 10 — недостаточно"}, + {Name: "Задолженность по комиссиям", OK: true, Warn: true}, + }, + }}} + code, short, text := lkDepoRejectText(res) + if code != "QTY" { + t.Errorf("код %q, ожидали QTY", code) + } + if short != "Отказ по результатам проверки депозитария: "+m2mCodeLabel("QTY") || strings.Contains(short, "Причина") { + t.Errorf("короткий текст для ЛК: %q", short) + } + for _, want := range []string{"Отказ по результатам проверки депозитария", "(QTY)", "Причина: Остаток ≥ количеству (98070.04): на торговом счёте"} { + if !strings.Contains(text, want) { + t.Errorf("в тексте нет %q: %s", want, text) + } + } + if strings.Contains(text, "Российский выпуск") || strings.Contains(text, "комиссиям") { + t.Errorf("пройденные проверки не должны попадать в причину: %s", text) + } +} + +func TestLKEmulateNSD(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + s := rc.Snapshot() + s.LK.ActiveEnv, s.NSD.Profile = "prod", "test3-gost" + if err := rc.UpdateLK(s.LK); err != nil { + t.Fatal(err) + } + if err := rc.UpdateNSD(s.NSD); err != nil { + t.Fatal(err) + } + if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом профиле") { + t.Errorf("боевой ЛК + тестовый ИШ: ожидали имитацию, получили %v %q", on, why) + } + s.LK.ActiveEnv = "test" + _ = rc.UpdateLK(s.LK) + if on, _ := lkEmulateNSD(rc); on { + t.Error("тестовый ЛК без флага: имитации быть не должно") + } + s.LK.EmulateNSD = true + _ = rc.UpdateLK(s.LK) + if on, why := lkEmulateNSD(rc); !on || why != "включена в настройках" { + t.Errorf("флаг в настройках: %v %q", on, why) + } +} diff --git a/internal/lkgateway/lkflow.go b/internal/lkgateway/lkflow.go new file mode 100644 index 0000000..41b4d87 --- /dev/null +++ b/internal/lkgateway/lkflow.go @@ -0,0 +1,387 @@ +package lkgateway + +// Путь заявки ЛК ЕСИА Финанс через шлюз bj (режим имитации НРД): шаги для карточки в +// журнале исходящих заявок и замыкание отказа через Fancy. +// +// заявка ЛК → проверка шлюза → отказ: сделка «Отклонена», в DEPO FTV_M2M (OUT) + строки +// + BJ_GATEWAY_REJECT по каждой строке → обработчик DEPO пометил события SUCCESS → ЛК «Отклонена» +// +// Статус в ЛК ставится только после успешной обработки в Fancy. Если обработчик ответил +// ERROR, заявка ждёт: оповещение, шаг в карточке, после возврата записей в очередь путь +// продолжается сам. Шаги хранятся по GUID перевода в lk-flow.json и показываются в +// таймлайне карточки (buildClaimTimeline). + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "os" + "sort" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" +) + +const lkFlowFile = "/var/lib/bj/.bj/lk-flow.json" + +// lkFlowDepoAlertAfter — через сколько без ответа Fancy поднимать оповещение (ждать продолжаем). +const lkFlowDepoAlertAfter = 10 * time.Minute + +// LKFlowStep — шаг пути заявки (Kind — класс шага таймлайна: db | crane | reject | gateway | response). +type LKFlowStep struct { + At time.Time `json:"at"` + Kind string `json:"kind"` + Title string `json:"title"` + Detail string `json:"detail,omitempty"` +} + +// LKFlow — путь одной заявки ЛК через шлюз. +type LKFlow struct { + GUID string `json:"guid"` + ClaimID int `json:"claim_id"` + Stage string `json:"stage,omitempty"` // depo_pending | lk_pending | done | sent + Code string `json:"code,omitempty"` + Reason string `json:"reason,omitempty"` // подробно: журнал bj и Fancy + Short string `json:"short,omitempty"` // коротко: комментарий клиенту в ЛК + DepoAt time.Time `json:"depo_at,omitempty"` // когда отказ записан в DEPO + DepoAlert bool `json:"depo_alert,omitempty"` + DepoFail string `json:"depo_fail,omitempty"` // последняя ошибка обработчика DEPO (уже показана) + LKReason string `json:"lk_reason,omitempty"` + LKAlert bool `json:"lk_alert,omitempty"` + Steps []LKFlowStep `json:"steps"` + UpdatedAt time.Time `json:"updated_at"` +} + +type lkFlowStore struct { + mu sync.Mutex + items map[string]*LKFlow +} + +var globalLKFlows = loadLKFlows() + +func loadLKFlows() *lkFlowStore { + s := &lkFlowStore{items: map[string]*LKFlow{}} + if b, err := os.ReadFile(lkFlowFile); err == nil { + var list []*LKFlow + if json.Unmarshal(b, &list) == nil { + for _, f := range list { + s.items[strings.ToUpper(f.GUID)] = f + } + } + } + return s +} + +func (s *lkFlowStore) saveLocked() { + list := make([]*LKFlow, 0, len(s.items)) + for _, f := range s.items { + list = append(list, f) + } + sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.Before(list[j].UpdatedAt) }) + b, _ := json.MarshalIndent(list, "", " ") + tmp := lkFlowFile + ".tmp" + if os.WriteFile(tmp, b, 0o600) == nil { + _ = os.Rename(tmp, lkFlowFile) + } +} + +func (s *lkFlowStore) Get(guid string) *LKFlow { + s.mu.Lock() + defer s.mu.Unlock() + f, ok := s.items[strings.ToUpper(guid)] + if !ok { + return nil + } + cp := *f + cp.Steps = append([]LKFlowStep(nil), f.Steps...) + return &cp +} + +func (s *lkFlowStore) update(guid string, fn func(f *LKFlow)) { + s.mu.Lock() + defer s.mu.Unlock() + k := strings.ToUpper(guid) + f, ok := s.items[k] + if !ok { + f = &LKFlow{GUID: guid} + s.items[k] = f + } + fn(f) + f.UpdatedAt = time.Now() + s.saveLocked() +} + +// step — добавить шаг пути (создаёт запись, если её нет). +func (s *lkFlowStore) step(guid string, claimID int, kind, title, detail string) { + s.update(guid, func(f *LKFlow) { + if claimID != 0 { + f.ClaimID = claimID + } + f.Steps = append(f.Steps, LKFlowStep{At: time.Now(), Kind: kind, Title: title, Detail: detail}) + }) +} + +// stepIfExists — шаг только для заявок, путь которых уже ведётся. +func (s *lkFlowStore) stepIfExists(guid, kind, title, detail string) { + s.mu.Lock() + _, ok := s.items[strings.ToUpper(guid)] + s.mu.Unlock() + if ok { + s.step(guid, 0, kind, title, detail) + } +} + +func (s *lkFlowStore) pending() []*LKFlow { + s.mu.Lock() + defer s.mu.Unlock() + var out []*LKFlow + for _, f := range s.items { + if f.Stage == "depo_pending" || f.Stage == "lk_pending" { + cp := *f + out = append(out, &cp) + } + } + return out +} + +// lkGatewayReject — отказ шлюза bj: сделка в журнале «Отклонена» с кодом, отказ уходит в +// Fancy (DEPO). Статус в ЛК поставит lkFlowLoop, когда обработчик DEPO успешно обработает отказ. +// short — короткий комментарий для клиента в ЛК, reason — подробный для журнала и Fancy. +func lkGatewayReject(ctx context.Context, rc *RuntimeConfig, svc *Service, id int, investorID string, req *m2m.M2MTransferRequest, code, short, reason string) { + guid := string(req.Header.GUID) + deal, err := svc.Repo().GetByGUID(ctx, req.Header.GUID) + if err != nil || deal == nil { + deal = nil + if nd, e := m2mcore.NewDeal(req.Header.GUID, investorID, nil); e == nil { + nd.Request = req + if saved, e := svc.Repo().Create(ctx, nd); e == nil { + deal = saved + } else { + log.Printf("lk-gateway: ЛК #%d: сделка для журнала не создана: %v", id, e) + } + } + } + if deal != nil && !m2mcore.IsTerminal(deal.State) { + if deal.Request == nil { + deal.Request = req + } + if e := deal.RejectManually(ctx, "bj-gateway", code, reason); e != nil { + log.Printf("lk-gateway: ЛК #%d: отказ по сделке %s: %v", id, shortGUID(guid), e) + } + if e := svc.Repo().Update(ctx, deal); e != nil { + log.Printf("lk-gateway: ЛК #%d: сохранение отказа: %v", id, e) + } + } + lkMarkSent(id) + lkRecordOrigin(guid, id) + globalLKFlows.update(guid, func(f *LKFlow) { + f.ClaimID, f.Stage, f.Code, f.Reason, f.Short = id, "depo_pending", code, reason, short + }) + lkFlowWriteDepo(rc, guid, req) +} + +// lkFlowWriteDepo — отказ шлюза в Fancy: шапка FTV_M2M (OUT) + строки + BJ_GATEWAY_REJECT +// по каждой строке. Идемпотентно (шапка по GUID, события по GUID+REFERENCE+EVENT_TYPE). +func lkFlowWriteDepo(rc *RuntimeConfig, guid string, req *m2m.M2MTransferRequest) bool { + f := globalLKFlows.Get(guid) + if f == nil || req == nil { + return false + } + inv := req.Data.InvestorInformation + ser := "" + if inv.IdentityDocument.DocumentSeries != nil { + ser = string(*inv.IdentityDocument.DocumentSeries) + } + num := string(inv.IdentityDocument.DocumentNumber) + place := rc.Snapshot().NSD.SectionID + var lines []depoLine + for _, s := range req.Data.TransferredSecurities.Securities { + lines = append(lines, depoLine{Ref: string(s.ReferenceID), ISIN: quikSecISIN(s), Code: string(s.SecurityCode), Amount: quikSecQty(s.Quantity), Place: place}) + } + inn := string(req.Data.ReceivingDepository.INN) + name := strings.TrimSpace(strings.Join([]string{inv.LastName, inv.FirstName, inv.MiddleName}, " ")) + comment := fmt.Sprintf("исходящий перевод M2M из ЛК #%d, получатель ИНН %s — отклонён шлюзом bj до отправки в НРД", f.ClaimID, inn) + if !depoUpsertOrder(rc, guid, "OUT", handbookCodeByINN(inn), name, strings.TrimSpace(ser+" "+num), ser, num, lines, comment) { + return false + } + for _, ln := range lines { + depoEvent(rc, guid, ln.Ref, "BJ_GATEWAY_REJECT", f.Code, f.Reason) + } + globalLKFlows.update(guid, func(x *LKFlow) { + if !x.DepoAt.IsZero() { + return + } + x.DepoAt = time.Now() + x.Steps = append(x.Steps, LKFlowStep{At: x.DepoAt, Kind: "db", Title: "Отказ передан в Fancy (DEPO)", + Detail: fmt.Sprintf("FTV_M2M (OUT) и событие BJ_GATEWAY_REJECT по строкам: %d. Ждём обработку в DEPO.", len(lines))}) + }) + return true +} + +// lkDepoAnswer — ответ Fancy на отказ по GUID. done — все BJ_GATEWAY_REJECT обработаны успешно; +// failed — текст ошибки обработчика (ERROR по событию, поручению или строке); events — сколько +// событий отказа записано. +func lkDepoAnswer(ctx context.Context, rc *RuntimeConfig, guid, ourComment string) (done bool, failed string, events int, text string, err error) { + db, err := depoEventsDB(rc) + if err != nil { + return false, "", 0, "", err + } + defer db.Close() + g := strings.ToUpper(guid) + rows, err := db.QueryContext(ctx, "SELECT ISNULL(STATE,''), ISNULL(COMMENT,'') FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g AND EVENT_TYPE='BJ_GATEWAY_REJECT'", sql.Named("g", g)) + if err != nil { + return false, "", 0, "", err + } + todo := 0 + var states, comments, errs []string + for rows.Next() { + var st, cm string + if rows.Scan(&st, &cm) != nil { + continue + } + events++ + cm = strings.TrimSpace(strings.TrimPrefix(cm, ourComment)) + switch strings.ToUpper(st) { + case "", "TODO": + todo++ + continue + case "ERROR": + if cm != "" && !containsStr(errs, cm) { + errs = append(errs, cm) + } + continue + } + if !containsStr(states, st) { + states = append(states, st) + } + if cm != "" && !containsStr(comments, cm) { + comments = append(comments, cm) + } + } + rows.Close() + + docCol := "NULL" + if depoHasColumn(ctx, db, "FTV_M2M", "DOC") { + docCol = "CAST(m.DOC AS varchar(32))" + } + var doc, mState, mComment, lineErr string + _ = db.QueryRowContext(ctx, `SELECT ISNULL(`+docCol+`,''), ISNULL(m.STATE,''), ISNULL(m.COMMENT,''), + ISNULL((SELECT TOP 1 ISNULL(l.COMMENT,'') FROM dbo.FTV_M2M_LINES l WHERE l.GUID=m.GUID AND ISNULL(l.STATE,'')='ERROR'),'') + FROM dbo.FTV_M2M m WHERE m.GUID=@g`, sql.Named("g", g)).Scan(&doc, &mState, &mComment, &lineErr) + if strings.EqualFold(mState, "ERROR") && mComment != "" && !containsStr(errs, mComment) { + errs = append(errs, mComment) + } + if lineErr != "" && !containsStr(errs, lineErr) { + errs = append(errs, lineErr) + } + + done = events > 0 && todo == 0 && len(errs) == 0 + failed = strings.Join(errs, "; ") + var parts []string + if done { + parts = append(parts, "событие отказа обработано в DEPO ("+strings.Join(states, ", ")+")") + } + if doc != "" && doc != "0" { + parts = append(parts, "документ DEPO №"+doc) + } + if len(comments) > 0 { + parts = append(parts, "комментарий DEPO: "+strings.Join(comments, "; ")) + } + return done, failed, events, strings.Join(parts, "; "), nil +} + +// lkFlowLoop — доводит отказы шлюза до ЛК: ждёт успешной обработки в Fancy и ставит «Отклонена». +func (s *Server) lkFlowLoop(ctx context.Context) { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + for _, f := range globalLKFlows.pending() { + lkFlowAdvance(ctx, s.rc, s.svc, f) + } + } +} + +func lkFlowAdvance(ctx context.Context, rc *RuntimeConfig, svc *Service, f *LKFlow) { + cctx, cancel := context.WithTimeout(ctx, 40*time.Second) + defer cancel() + if f.Stage == "depo_pending" { + var ( + done bool + failed, text string + events int + err error + ) + if !f.DepoAt.IsZero() { + done, failed, events, text, err = lkDepoAnswer(cctx, rc, f.GUID, f.Reason) + if err != nil { + return + } + } + if f.DepoAt.IsZero() || events == 0 { // в DEPO ещё не записано (база была недоступна) — повторяем + if deal, e := svc.Repo().GetByGUID(cctx, m2m.UUID(f.GUID)); e == nil && deal != nil && deal.Request != nil { + lkFlowWriteDepo(rc, f.GUID, deal.Request) + } + return + } + if failed != "" { // обработчик DEPO ответил ошибкой — в ЛК не пишем, ждём возврата в очередь + if failed != f.DepoFail { + globalLKFlows.update(f.GUID, func(x *LKFlow) { + x.DepoFail = failed + x.Steps = append(x.Steps, LKFlowStep{At: time.Now(), Kind: "reject", Title: "Fancy не смог обработать отказ", + Detail: failed + ". Статус в ЛК не меняется: устраните причину и верните записи в очередь (Обмен с DEPO)."}) + }) + raiseSystemAlert(rc, fmt.Sprintf("Fancy: ошибка обработки отказа по заявке ЛК #%d", f.ClaimID), + fmt.Sprintf("Перевод %s: %s. Статус в ЛК не изменён. Устраните причину и нажмите «Вернуть в очередь» на странице «Обмен с DEPO».", shortGUID(f.GUID), failed)) + log.Printf("lk-gateway: ЛК #%d (%s): обработчик DEPO вернул ошибку — %s", f.ClaimID, shortGUID(f.GUID), failed) + } + return + } + if !done { + if !f.DepoAlert && time.Since(f.DepoAt) > lkFlowDepoAlertAfter { + globalLKFlows.update(f.GUID, func(x *LKFlow) { x.DepoAlert = true }) + raiseSystemAlert(rc, fmt.Sprintf("Fancy не обработал отказ по заявке ЛК #%d", f.ClaimID), + fmt.Sprintf("Перевод %s: событие BJ_GATEWAY_REJECT дольше %s ждёт обработки. Статус в ЛК не изменён — проверьте обработку DEPO.", shortGUID(f.GUID), lkFlowDepoAlertAfter)) + } + return + } + // клиенту — только короткий итог проверки; подробности остаются в журнале bj и в Fancy + lkReason := f.Short + if lkReason == "" { + lkReason, _, _ = strings.Cut(f.Reason, ". Причина:") + } + globalLKFlows.update(f.GUID, func(x *LKFlow) { + x.Stage, x.LKReason, x.DepoFail = "lk_pending", lkReason, "" + x.Steps = append(x.Steps, LKFlowStep{At: time.Now(), Kind: "response", Title: "Ответ Fancy (DEPO): отказ обработан", Detail: text}) + }) + log.Printf("lk-gateway: ЛК #%d (%s): Fancy обработал отказ — %s", f.ClaimID, shortGUID(f.GUID), text) + f.Stage, f.LKReason = "lk_pending", lkReason + } + if f.Stage == "lk_pending" { + ep, ok := lkActiveEndpoint(rc) + if !ok { + return + } + pc, pb := lkPatchRejected(cctx, ep, f.ClaimID, f.LKReason) + if pc >= 400 || pc == 0 { + if !f.LKAlert { + globalLKFlows.update(f.GUID, func(x *LKFlow) { x.LKAlert = true }) + raiseSystemAlert(rc, "ЛК: не удалось отклонить заявку", fmt.Sprintf("Заявка ЛК #%d: Fancy отказ обработал, но статус в ЛК не изменён: HTTP %d %s. bj повторяет каждые 15 с.", f.ClaimID, pc, pb)) + } + return + } + globalLKFlows.update(f.GUID, func(x *LKFlow) { + x.Stage = "done" + x.Steps = append(x.Steps, LKFlowStep{At: time.Now(), Kind: "reject", Title: "Статус в ЛК: «Отклонена»", Detail: f.LKReason}) + }) + } +} diff --git a/internal/lkgateway/lkmatch_live_test.go b/internal/lkgateway/lkmatch_live_test.go new file mode 100644 index 0000000..53385b4 --- /dev/null +++ b/internal/lkgateway/lkmatch_live_test.go @@ -0,0 +1,147 @@ +//go:build live + +package lkgateway + +// Разбор, какие данные пришли из боевого ЛК по заявке и как клиент сопоставлен с DEPO. +// Только чтение: GET из ЛК, SELECT из DEPO. Паспорт и ФИО в выводе маскируются. +// go test -c -tags live -o lkmatch.test ./internal/lkgateway/ && sudo ./lkmatch.test -test.run TestLKMatchLive -test.v + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + "testing" +) + +func mask(s string) string { + r := []rune(strings.TrimSpace(s)) + switch { + case len(r) == 0: + return "(пусто)" + case len(r) <= 2: + return strings.Repeat("•", len(r)) + } + return string(r[:1]) + strings.Repeat("•", len(r)-2) + string(r[len(r)-1:]) +} + +func TestLKMatchLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + ep, ok := lkActiveEndpoint(rc) + if !ok { + t.Skip("ЛК не настроен") + } + id := 69753 + if v := os.Getenv("CLAIM"); v != "" { + id, _ = strconv.Atoi(v) + } + ctx := context.Background() + _, body, err := lkGet(ctx, ep, "/claims/"+strconv.Itoa(id)) + if err != nil { + t.Fatal(err) + } + var wrap struct { + Claim map[string]any `json:"claim"` + } + _ = json.Unmarshal(body, &wrap) + cl := wrap.Claim + data, _ := cl["data"].(map[string]any) + fmt.Printf("== Заявка ЛК #%d: kind=%v, user_id=%v, owner=%v/%v\n", id, cl["kind"], cl["user_id"], cl["owner_type"], cl["owner_id"]) + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b, _ := json.Marshal(data[k]) + fmt.Printf(" data.%s = %s\n", k, b) + } + + uid := toInt(cl["user_id"]) + _, ub, _ := lkGet(ctx, ep, "/users/"+strconv.Itoa(uid)) + var uw map[string]any + _ = json.Unmarshal(ub, &uw) + u := pickMap(uw, "user", "data") + if u == nil { + u = uw + } + fmt.Printf("== Пользователь ЛК #%d: поля карточки (значения ФИО и документа замаскированы)\n", uid) + ukeys := make([]string, 0, len(u)) + for k := range u { + ukeys = append(ukeys, k) + } + sort.Strings(ukeys) + var present []string + for _, k := range ukeys { + if s := str(u[k]); s != "" && s != "" { + present = append(present, k) + } + } + fmt.Printf(" заполненные поля: %s\n", strings.Join(present, ", ")) + for _, k := range []string{"surname", "name", "patronymic", "document_type", "pass_series", "pass_number", "document_series", "document_number", "birth_date", "inn", "snils"} { + if v, ok := u[k]; ok { + val := str(v) + if k != "document_type" { + val = mask(val) + } + fmt.Printf(" %s = %s\n", k, val) + } + } + + req, err := lkClaimToRequest(ctx, rc, ep, cl, data) + if err != nil { + t.Fatal(err) + } + inv := req.Investor + fmt.Printf("== В сверку с DEPO передано: ФИО %s %s %s, документ тип %s серия %s номер %s\n", + mask(inv.LastName), mask(inv.FirstName), mask(inv.MiddleName), inv.Document.DocumentType, mask(inv.Document.Series), mask(inv.Document.Number)) + var secs []depoOutSec + acc, section := "", "" + for _, s := range req.Securities { + secs = append(secs, depoOutSec{ISIN: s.SecurityDetails.ISIN, Code: s.SecurityCode, Qty: strconv.FormatUint(s.Quantity.Whole, 10)}) + if len(s.SettlementAccounts) > 0 { + acc, section = s.SettlementAccounts[0].SettlementLocation.AccountID, s.SettlementAccounts[0].SettlementLocation.SectionID + } + } + if v := os.Getenv("ACCOUNT"); v != "" { + req.ClientAccount = v // проверка несовпадения: подставить чужой счёт + } + fmt.Printf("== Счёт из заявки: %s\n", req.ClientAccount) + if v := os.Getenv("IIS_ACCOUNT"); v != "" { // признак ИИС по произвольному счёту + fs := rc.Snapshot().Fancy + if db, err := fancyOpen(fs, fs.DepoDB()); err == nil { + var cid int64 + _ = db.QueryRowContext(ctx, "SELECT TOP 1 ID FROM OD_CLIENTS WHERE NUMBER=@p1", sql.Named("p1", v)).Scan(&cid) + ok, det := depoAccountIIS(ctx, db, fs, cid) + fmt.Printf("== ИИС по счёту %s: не ИИС=%v — %s\n", v, ok, det) + db.Close() + } + } + if v := os.Getenv("FIO_LAST"); v != "" { + inv.LastName = v // проверка расхождения ФИО + } + if v := os.Getenv("BIRTH"); v != "" { + inv.BirthDate = v // проверка расхождения даты рождения + } + res := runDepoCheckOutAccount(inv.LastName, inv.FirstName, inv.MiddleName, inv.Document.Series, inv.Document.Number, inv.BirthDate, secs, acc, section, req.ClientAccount) + fmt.Printf("== Сверка с DEPO: итог %s; лицо в DEPO: %s; счёт: %s\n", res.Summary, mask(res.Investor), res.Account) + for _, s := range res.Secs { + for _, c := range s.Checks { + mark := "✓" + if !c.OK { + mark = "✗" + } else if c.Warn { + mark = "!" + } + fmt.Printf(" %s %s [%s]: %s\n", mark, c.Name, c.Code, c.Detail) + } + } +} diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go new file mode 100644 index 0000000..b6e338f --- /dev/null +++ b/internal/lkgateway/lkstore.go @@ -0,0 +1,319 @@ +package lkgateway + +// Данные модуля личного кабинета клиента (bj-lk) в нашей PostgreSQL (база bj, схема lk): +// пользователи, позиции клиентов, заявки на перевод и итоги по ним. До 15.09.2026 лежали +// в базе обмена bj_exchange на MSSQL (схема «крана» Fancy); данные перенесены, «кран» убран. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +var ( + lkDBMu sync.Mutex + lkDBDSN string + lkDBConn *sql.DB +) + +// lkDB — общий пул подключений к PostgreSQL bj (Postgres.DSN). Закрывать не нужно. +func lkDB(rc *RuntimeConfig) (*sql.DB, error) { + dsn := strings.TrimSpace(rc.Snapshot().Postgres.DSN) + if dsn == "" { + return nil, errors.New("база PostgreSQL bj не настроена") + } + lkDBMu.Lock() + defer lkDBMu.Unlock() + if lkDBConn != nil && lkDBDSN == dsn { + return lkDBConn, nil + } + if lkDBConn != nil { + lkDBConn.Close() + } + db, err := sql.Open("pgx", dsn) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(4) + db.SetConnMaxLifetime(10 * time.Minute) + lkDBConn, lkDBDSN = db, dsn + return db, nil +} + +// lkDBReady — PostgreSQL bj доступна (для /api/lk/ping). +func lkDBReady(rc *RuntimeConfig) bool { + db, err := lkDB(rc) + if err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return db.PingContext(ctx) == nil +} + +var lkSchemaDDL = []string{ + `CREATE SCHEMA IF NOT EXISTS lk`, + `CREATE TABLE IF NOT EXISTS lk.users ( + id BIGSERIAL PRIMARY KEY, login VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(256) NOT NULL, + contract_number VARCHAR(64) NOT NULL, full_name VARCHAR(128), last_name VARCHAR(64), first_name VARCHAR(64), + middle_name VARCHAR(64), doc_type VARCHAR(8), doc_series VARCHAR(16), doc_number VARCHAR(32), email VARCHAR(128), + phone VARCHAR(32), active BOOLEAN NOT NULL DEFAULT true, is_admin BOOLEAN NOT NULL DEFAULT false, + can_transfer BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_login_at TIMESTAMPTZ)`, + `CREATE TABLE IF NOT EXISTS lk.client_positions ( + id BIGSERIAL PRIMARY KEY, contract_number VARCHAR(64) NOT NULL, client_name VARCHAR(128), isin VARCHAR(20), + security_code VARCHAR(40), security_name VARCHAR(256), quantity NUMERIC(28,8), blocked NUMERIC(28,8), + available NUMERIC(28,8), deponent_code VARCHAR(16), account_id VARCHAR(40), section_id VARCHAR(40), + settlement_inn VARCHAR(16), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`, + `CREATE INDEX IF NOT EXISTS ix_lk_positions_contract ON lk.client_positions(contract_number)`, + `CREATE TABLE IF NOT EXISTS lk.transfer_requests ( + id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), status VARCHAR(32) NOT NULL DEFAULT 'new', + source VARCHAR(16), bj_claim_id VARCHAR(64), lk_request_id VARCHAR(64), contract_number VARCHAR(64), + lk_investor_id VARCHAR(64), signed_document TEXT, signature_format VARCHAR(32), guid VARCHAR(40) NOT NULL, + creation_timestamp VARCHAR(40), sender_code VARCHAR(16), receiver_code VARCHAR(16), cost_info VARCHAR(4), + cost_code VARCHAR(16), investor_last VARCHAR(64), investor_first VARCHAR(64), investor_middle VARCHAR(64), + doc_type VARCHAR(8), doc_series VARCHAR(16), doc_number VARCHAR(32), iia_agreement_type VARCHAR(8), + iia_agreement_number VARCHAR(128), iia_agreement_date VARCHAR(16), iia_broker_inn VARCHAR(16), + transferring_inn VARCHAR(16), receiving_inn VARCHAR(16), operation_type VARCHAR(8), request_xml TEXT, lk_payload TEXT)`, + `CREATE INDEX IF NOT EXISTS ix_lk_requests_guid ON lk.transfer_requests(guid)`, + `CREATE INDEX IF NOT EXISTS ix_lk_requests_contract ON lk.transfer_requests(contract_number)`, + `CREATE TABLE IF NOT EXISTS lk.transfer_securities ( + id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES lk.transfer_requests(id), seq INT NOT NULL, + reference_id VARCHAR(40), security_code VARCHAR(40), isin VARCHAR(20), sec_classification VARCHAR(32), + sec_category VARCHAR(32), sec_type VARCHAR(64), sec_series VARCHAR(64), ident_reg_number VARCHAR(32), + fund_reg_number VARCHAR(256), fund_class VARCHAR(128), quantity_whole BIGINT, quantity_fractional VARCHAR(40), + settlement_inn VARCHAR(16), deponent_code VARCHAR(16), account_id VARCHAR(40), section_id VARCHAR(40), + settlement_count INT, settlements_json TEXT, isolation_status VARCHAR(16))`, + `CREATE INDEX IF NOT EXISTS ix_lk_securities_request ON lk.transfer_securities(request_id)`, + `CREATE TABLE IF NOT EXISTS lk.transfer_responses ( + id BIGSERIAL PRIMARY KEY, request_id BIGINT, security_id BIGINT, guid VARCHAR(40), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), verdict VARCHAR(32), reason VARCHAR(512), checked_by VARCHAR(64), + check_source VARCHAR(64), securities_total INT, securities_ok INT, securities_failed INT, payload_json TEXT)`, + `CREATE INDEX IF NOT EXISTS ix_lk_responses_request ON lk.transfer_responses(request_id)`, +} + +// ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj. +func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error { + db, err := lkDB(rc) + if err != nil { + return err + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + for _, q := range lkSchemaDDL { + if _, err := db.ExecContext(cctx, q); err != nil { + return err + } + } + return nil +} + +// ClientPosition — остаток бумаги на счёте клиента (для Личного кабинета). +type ClientPosition struct { + ISIN string `json:"isin"` + SecurityCode string `json:"security_code"` + SecurityName string `json:"security_name"` + Quantity float64 `json:"quantity"` + Blocked float64 `json:"blocked"` + Available float64 `json:"available"` + DeponentCode string `json:"deponent_code"` + AccountID string `json:"account_id"` + SectionID string `json:"section_id"` + SettlementIN string `json:"settlement_inn"` + ClientName string `json:"client_name"` + UpdatedAt string `json:"updated_at"` + // Transferable — бумагу можно перевести через M2M (только российские выпуски). + Transferable bool `json:"transferable"` + Note string `json:"note"` +} + +// lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты; +// реальный портфель берётся из DEPO). +func lkClientPositions(ctx context.Context, rc *RuntimeConfig, contract string) ([]ClientPosition, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + rows, err := db.QueryContext(cctx, `SELECT COALESCE(isin,''), COALESCE(security_code,''), COALESCE(security_name,''), + COALESCE(quantity,0), COALESCE(blocked,0), COALESCE(available,0), + COALESCE(deponent_code,''), COALESCE(account_id,''), COALESCE(section_id,''), + COALESCE(settlement_inn,''), COALESCE(client_name,''), to_char(updated_at,'DD.MM.YYYY HH24:MI') + FROM lk.client_positions WHERE contract_number=$1 ORDER BY security_name, isin`, contract) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ClientPosition + for rows.Next() { + var p ClientPosition + if err := rows.Scan(&p.ISIN, &p.SecurityCode, &p.SecurityName, &p.Quantity, &p.Blocked, &p.Available, + &p.DeponentCode, &p.AccountID, &p.SectionID, &p.SettlementIN, &p.ClientName, &p.UpdatedAt); err != nil { + return out, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// LKRequestMeta — метаданные заявки модуля ЛК. +type LKRequestMeta struct { + Source string // 'lk' + BJClaimID string // ID сделки bj + LKInvestorID string // investor.id + ContractNumber string // № договора клиента + SignedDocument string // подпись клиента (base64) + SignatureFormat string // формат подписи + RequestXML string // M2MTransferRequest (UTF-8, аудит) +} + +// lkInsertRequest — заявка модуля ЛК (шапка и бумаги) в lk.transfer_requests / lk.transfer_securities. +func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest, meta LKRequestMeta) (int64, error) { + db, err := lkDB(rc) + if err != nil { + return 0, err + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + costInfo, costCode := "No", "" + if req.Header.CostInfo.Yes != nil { + costInfo, costCode = "Yes", string(req.Header.CostInfo.Yes.Code) + } + inv := req.Data.InvestorInformation + doc := inv.IdentityDocument + docSeries := "" + if doc.DocumentSeries != nil { + docSeries = string(*doc.DocumentSeries) + } + ourINN := transferringINN(Settings{}) + opType := "" + switch ourINN { + case string(req.Data.TransferringDepository.INN): + opType = "debit" + case string(req.Data.ReceivingDepository.INN): + opType = "credit" + } + tx, err := db.BeginTx(cctx, nil) + if err != nil { + return 0, err + } + var id int64 + err = tx.QueryRowContext(cctx, `INSERT INTO lk.transfer_requests (source, bj_claim_id, lk_investor_id, signed_document, signature_format, + guid, creation_timestamp, sender_code, receiver_code, cost_info, cost_code, investor_last, investor_first, investor_middle, + doc_type, doc_series, doc_number, transferring_inn, receiving_inn, operation_type, contract_number, request_xml, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,'new') RETURNING id`, + meta.Source, meta.BJClaimID, meta.LKInvestorID, meta.SignedDocument, meta.SignatureFormat, + string(req.Header.GUID), req.Header.CreationTimestamp.String(), string(req.Header.SenderCode), string(req.Header.ReceiverCode), costInfo, costCode, + inv.LastName, inv.FirstName, inv.MiddleName, doc.DocumentType, docSeries, string(doc.DocumentNumber), + string(req.Data.TransferringDepository.INN), string(req.Data.ReceivingDepository.INN), opType, meta.ContractNumber, meta.RequestXML).Scan(&id) + if err != nil { + tx.Rollback() + return 0, fmt.Errorf("запись заявки: %w", err) + } + for i, sec := range req.Data.TransferredSecurities.Securities { + isin := "" + if sec.SecurityDetails.ISIN != nil { + isin = string(*sec.SecurityDetails.ISIN) + } + var qWhole, qFrac any + if sec.Quantity.Whole != nil { + qWhole = int64(*sec.Quantity.Whole) + } else if sec.Quantity.Fractional != nil { + qFrac = string(*sec.Quantity.Fractional) + } + settINN, depCode, accID, sectID := "", "", "", "" + if len(sec.SettlementAccount) > 0 { + sa := sec.SettlementAccount[0] + settINN, depCode, accID, sectID = string(sa.SettlementRequisites.INN), sa.SettlementLocation.DeponentCode, string(sa.SettlementLocation.AccountID), sa.SettlementLocation.SectionID + } + settJSON := "" + if len(sec.SettlementAccount) > 1 { + if b, e := json.Marshal(sec.SettlementAccount); e == nil { + settJSON = string(b) + } + } + if _, err := tx.ExecContext(cctx, `INSERT INTO lk.transfer_securities (request_id, seq, reference_id, security_code, isin, + quantity_whole, quantity_fractional, settlement_inn, deponent_code, account_id, section_id, settlement_count, settlements_json, isolation_status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + id, i+1, string(sec.ReferenceID), string(sec.SecurityCode), isin, qWhole, qFrac, settINN, depCode, accID, sectID, + len(sec.SettlementAccount), settJSON, string(sec.IsolationStatus)); err != nil { + tx.Rollback() + return 0, fmt.Errorf("запись бумаги %d: %w", i+1, err) + } + } + return id, tx.Commit() +} + +// lkMarkStatus — статус заявки модуля ЛК (sent | error | ...). +func lkMarkStatus(ctx context.Context, rc *RuntimeConfig, id int64, status string) error { + db, err := lkDB(rc) + if err != nil { + return err + } + cctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + _, err = db.ExecContext(cctx, "UPDATE lk.transfer_requests SET status=$1 WHERE id=$2", status, id) + return err +} + +// lkFinalizeByGUID — итог НРД для заявки модуля ЛК: статус (done | declined) и причина, +// чтобы клиент видел результат. Заявки не из модуля ЛК молча пропускаются. +func lkFinalizeByGUID(ctx context.Context, rc *RuntimeConfig, guid, status, verdict, reason string) error { + db, err := lkDB(rc) + if err != nil { + return nil // PostgreSQL не настроена — модуля ЛК нет + } + cctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + var id int64 + if err := db.QueryRowContext(cctx, "SELECT id FROM lk.transfer_requests WHERE guid=$1", guid).Scan(&id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + if _, err := db.ExecContext(cctx, "UPDATE lk.transfer_requests SET status=$1 WHERE id=$2", status, id); err != nil { + return err + } + _, err = db.ExecContext(cctx, `INSERT INTO lk.transfer_responses (request_id, guid, verdict, reason, checked_by, check_source) + VALUES ($1,$2,$3,$4,'НРД','MOEX МОСТ')`, id, guid, verdict, reason) + return err +} + +// lkClaimsByContract — заявки клиента модуля ЛК по номеру договора. +func lkClaimsByContract(ctx context.Context, rc *RuntimeConfig, contract string, limit int) ([]LKClaimRow, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if limit <= 0 || limit > 500 { + limit = 100 + } + rows, err := db.QueryContext(cctx, `SELECT r.id, r.guid, r.status, to_char(r.created_at,'DD.MM.YYYY HH24:MI'), + (SELECT COUNT(*) FROM lk.transfer_securities x WHERE x.request_id=r.id), + COALESCE((SELECT reason FROM lk.transfer_responses y WHERE y.request_id=r.id ORDER BY y.id DESC LIMIT 1),'') + FROM lk.transfer_requests r WHERE r.contract_number=$1 ORDER BY r.id DESC LIMIT $2`, contract, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKClaimRow + for rows.Next() { + var c LKClaimRow + if err := rows.Scan(&c.ID, &c.GUID, &c.Status, &c.CreatedAt, &c.Securit, &c.Reason); err != nil { + return out, err + } + c.StatusRu = lkStatusRu(c.Status) + out = append(out, c) + } + return out, rows.Err() +} diff --git a/internal/lkgateway/lktransfer.go b/internal/lkgateway/lktransfer.go new file mode 100644 index 0000000..fba47e5 --- /dev/null +++ b/internal/lkgateway/lktransfer.go @@ -0,0 +1,256 @@ +package lkgateway + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "strings" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// lkInvestorUUID — стабильный UUID инвестора, выведенный из номера договора. +// Нужен, потому что заявка (Deal) хранится в Postgres с investor_id типа UUID, +// а в ЛК клиент идентифицируется договором (BR200) и логином. +func lkInvestorUUID(contract string) string { + sum := sha1.Sum([]byte("bj-lk:" + strings.ToUpper(strings.TrimSpace(contract)))) + h := hex.EncodeToString(sum[:]) + // оформляем как UUID v5-подобный (8-4-4-4-12) + return fmt.Sprintf("%s-%s-5%s-a%s-%s", h[0:8], h[8:12], h[13:16], h[17:20], h[20:32]) +} + +// Заявка на перевод бумаг, поданная клиентом из Личного кабинета. +// Путь: ЛК → bj-server → запись в PostgreSQL (lk.transfer_requests) → M2M в НРД +// (в тестовом режиме — имитатор НРД) → итог по ответу НРД (lkFinalizeByGUID). + +// LKTransferSecurity — одна бумага в заявке клиента. +type LKTransferSecurity struct { + ISIN string `json:"isin"` + Quantity float64 `json:"quantity"` +} + +// LKTransferRequest — запрос на перевод из личного кабинета. +type LKTransferRequest struct { + ReceivingINN string `json:"receiving_inn"` // ИНН принимающего депозитария (куда переводим) + ReceivingName string `json:"receiving_name"` // наименование (для журнала) + AccountAtRecv string `json:"account_at_recv"` // счёт депо клиента у принимающего брокера (если известен) + Securities []LKTransferSecurity `json:"securities"` + Comment string `json:"comment"` +} + +// LKTransferResult — ответ клиенту. +type LKTransferResult struct { + OK bool `json:"ok"` + RequestID int64 `json:"request_id"` + GUID string `json:"guid"` + Status string `json:"status"` + Message string `json:"message"` +} + +// LKClaimRow — заявка клиента для отображения в ЛК. +type LKClaimRow struct { + ID int64 `json:"id"` + GUID string `json:"guid"` + Status string `json:"status"` + StatusRu string `json:"status_ru"` + CreatedAt string `json:"created_at"` + Securit int `json:"securities"` + Reason string `json:"reason"` +} + +// lkStatusRu — человекочитаемый статус заявки для клиента. +func lkStatusRu(s string) string { + switch s { + case "new": + return "Принята, ожидает проверки" + case "checking": + return "На проверке" + case "ready": + return "Проверена, отправляется в НРД" + case "sent": + return "Отправлена в НРД" + case "rejected": + return "Отклонена" + case "declined": + return "Отклонена НРД" + case "done": + return "Завершена" + case "error": + return "Ошибка обработки" + } + return s +} + +// CreateLKTransfer формирует заявку клиента, сохраняет её и отправляет в НРД. +// Проверяет, что бумаги есть в портфеле клиента и количество доступно. +func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKUser, in LKTransferRequest) (LKTransferResult, error) { + var res LKTransferResult + fancy := rc.Snapshot().Fancy + if _, err := lkDB(rc); err != nil { + return res, errors.New("сервис временно недоступен (БД не настроена)") + } + if strings.TrimSpace(in.ReceivingINN) == "" { + return res, errors.New("укажите принимающий депозитарий") + } + if len(in.Securities) == 0 { + return res, errors.New("выберите хотя бы одну бумагу") + } + // Подача разрешена только клиентам с флагом can_transfer (тестовые); + // реальные депозитарные клиенты работают в режиме просмотра. + if !u.CanTransfer { + return res, errors.New("для этого договора подача заявок недоступна (режим просмотра)") + } + // Портфель клиента — источник реквизитов и контроль доступного остатка. + // Сначала реальный депозитарный учёт (тот же источник, что видит клиент в ЛК), + // иначе — позиции модуля ЛК (lk.client_positions). + var pos []ClientPosition + if dp, derr := depoClientPositions(ctx, fancy, u.Contract); derr == nil && len(dp) > 0 { + pos = depoPositionsAsClient(dp) + } else { + p2, err := lkClientPositions(ctx, rc, u.Contract) + if err != nil { + return res, fmt.Errorf("не удалось получить портфель: %w", err) + } + pos = p2 + } + pos = markTransferable(pos) + byISIN := map[string]ClientPosition{} + for _, p := range pos { + byISIN[strings.ToUpper(p.ISIN)] = p + } + + nsd := rc.Snapshot().NSD + var secs []ClaimSec + for _, want := range in.Securities { + key := strings.ToUpper(strings.TrimSpace(want.ISIN)) + p, ok := byISIN[key] + if !ok { + return res, fmt.Errorf("бумага %s не найдена в вашем портфеле", want.ISIN) + } + if !p.Transferable { + return res, fmt.Errorf("бумага %s (%s) — иностранный выпуск, перевод через M2M недоступен: "+ + "в MOEX МОСТ переводятся только российские ценные бумаги", p.ISIN, p.SecurityName) + } + if want.Quantity <= 0 { + return res, fmt.Errorf("укажите количество для %s", want.ISIN) + } + if want.Quantity > p.Available { + return res, fmt.Errorf("по %s доступно к переводу %.0f, запрошено %.0f", want.ISIN, p.Available, want.Quantity) + } + dep, acc, sect := p.DeponentCode, p.AccountID, p.SectionID + if dep == "" { + dep = nsd.DeponentCode + } + if acc == "" { + acc = nsd.AccountID + } + if sect == "" { + sect = nsd.SectionID + } + code := p.SecurityCode + if code == "" { + code = p.ISIN + } + // ИНН реквизитов расчётов есть только в таблице обмена; у депозитарных + // позиций его нет — подставляем ИНН нашего депозитария (место расчётов НРД). + setlINN := p.SettlementIN + if setlINN == "" { + setlINN = transferringINN(rc.Snapshot()) + } + secs = append(secs, ClaimSec{ + SecurityCode: code, + SecurityDetails: SecurityDetails{ISIN: p.ISIN}, + Quantity: Quantity{Whole: uint64(want.Quantity)}, + SettlementAccounts: []SettlementAccount{{ + SettlementRequisitesINN: setlINN, + SettlementLocation: SettlementLocation{ + DeponentCode: dep, AccountID: acc, SectionID: sect, + }, + }}, + }) + } + + // ФИО/документ клиента — для M2MTransferRequest. + last, first, middle := u.LastName, u.FirstName, u.MiddleName + if last == "" && u.FullName != "" { + parts := strings.Fields(u.FullName) + if len(parts) > 0 { + last = parts[0] + } + if len(parts) > 1 { + first = parts[1] + } + if len(parts) > 2 { + middle = parts[2] + } + } + doc := Document{DocumentType: u.DocType, Series: u.DocSeries, Number: u.DocNumber} + claim := CreateClaimRequest{ + Investor: Investor{ + ID: u.Contract, + LastName: last, FirstName: first, MiddleName: middle, + Document: doc, + }, + TransferringDepositoryINN: transferringINN(rc.Snapshot()), + ReceivingDepositoryINN: strings.TrimSpace(in.ReceivingINN), + CostInfo: CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}}, + Securities: secs, + SignatureFormat: "LK", + } + // ТЕСТОВЫЙ КЛИЕНТ: если серия ДУЛ совпадает с кодом сценария робота НРД + // (1111/2001/2002/3333), заявка адресуется роботу-тестировщику, а серия/номер + // документа задают сценарий. Так из ЛК можно прогнать полный цикл с НРД. + if _, isRobot := robotScenarios[u.DocSeries]; isRobot { + claim.ReceiverCodeOverride = NSDRobotCode + claim.InvestorDocumentOverride = &doc + } + // Регистрируем клиента ЛК в справочнике (анкета + депо-счета из портфеля), + // иначе EnrichRequest не найдёт его данные. Депозитарным позициям (без своих + // НРД-реквизитов) подставляем фиксированное место расчётов нашего депозитария. + if ss, ok := s.store.(*SeedStore); ok { + ss.EnsureLKClient(u, pos, m2mcore.DepoAccount{ + DeponentCode: nsd.DeponentCode, + AccountID: m2m.AccountID(nsd.AccountID), + SectionID: nsd.SectionID, + DepositoryINN: m2m.OrganizationINN(transferringINN(rc.Snapshot())), + }) + } + req, err := s.BuildRequest(ctx, claim) + if err != nil { + return res, fmt.Errorf("не удалось сформировать поручение: %w", err) + } + xmlBytes, err := nsdxml.Marshal(req) + if err != nil { + return res, err + } + meta := LKRequestMeta{ + Source: "lk", + BJClaimID: string(req.Header.GUID), + LKInvestorID: lkInvestorUUID(u.Contract), // UUID — его ждёт хранилище заявок + ContractNumber: u.Contract, + SignatureFormat: "LK", + RequestXML: string(nsdxml.DecodeWindows1251(xmlBytes)), + } + id, err := lkInsertRequest(ctx, rc, req, meta) + if err != nil { + return res, fmt.Errorf("не удалось сохранить заявку: %w", err) + } + // Заявка сразу уходит в НРД; в тестовом режиме (боевой ЛК при тестовом ИШ) — имитатору НРД. + sender := s.sender + if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil { + sender = lkEmuSender + } + if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil { + _ = lkMarkStatus(ctx, rc, id, "error") + return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err) + } + _ = lkMarkStatus(ctx, rc, id, "sent") + return LKTransferResult{ + OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "sent", + Message: "Заявка принята и отправлена в НРД. Итог появится в разделе «Мои заявки».", + }, nil +} diff --git a/internal/lkgateway/lkusers.go b/internal/lkgateway/lkusers.go new file mode 100644 index 0000000..8be276c --- /dev/null +++ b/internal/lkgateway/lkusers.go @@ -0,0 +1,148 @@ +package lkgateway + +import ( + "context" + "errors" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// Пользователи Личного кабинета клиента. Хранятся в PostgreSQL bj (lk.users), пароль — +// только bcrypt-хеш. Клиент входит по логину/паролю, его портфель и заявки определяются +// номером договора (contract_number). + +// LKUser — учётная запись клиента в Личном кабинете. +type LKUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Contract string `json:"contract_number"` + FullName string `json:"full_name"` + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name"` + DocType string `json:"doc_type"` + DocSeries string `json:"doc_series"` + DocNumber string `json:"doc_number"` + Email string `json:"email"` + Phone string `json:"phone"` + Active bool `json:"active"` + IsAdmin bool `json:"is_admin"` + // CanTransfer — клиенту разрешена подача заявок на перевод. Для реальных + // депозитарных клиентов false (только просмотр); включаем точечно тестовым. + CanTransfer bool `json:"can_transfer"` + // ImpersonatedBy — логин администратора, если он вошёл от имени этого клиента. + ImpersonatedBy string `json:"impersonated_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastLoginAt time.Time `json:"last_login_at,omitempty"` +} + +var errLKAuth = errors.New("неверный логин или пароль") + +// lkCreateUser заводит пользователя ЛК (пароль хешируется bcrypt). +func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password string) error { + if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" { + return errors.New("логин и номер договора обязательны") + } + if len(password) < 6 { + return errors.New("пароль минимум 6 символов") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + db, err := lkDB(rc) + if err != nil { + return err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + _, err = db.ExecContext(cctx, `INSERT INTO lk.users (login, password_hash, contract_number, full_name, email, phone, active, + last_name, first_name, middle_name, doc_type, doc_series, doc_number, is_admin, can_transfer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, + u.Login, string(hash), u.Contract, u.FullName, u.Email, u.Phone, u.Active, + u.LastName, u.FirstName, u.MiddleName, u.DocType, u.DocSeries, u.DocNumber, u.IsAdmin, u.CanTransfer) + return err +} + +// lkSetPassword меняет пароль пользователя ЛК. +func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error { + if len(password) < 6 { + return errors.New("пароль минимум 6 символов") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + db, err := lkDB(rc) + if err != nil { + return err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + res, err := db.ExecContext(cctx, "UPDATE lk.users SET password_hash=$1 WHERE login=$2", string(hash), login) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return errors.New("пользователь не найден") + } + return nil +} + +// lkAuth проверяет логин/пароль клиента. Возвращает пользователя при успехе. +func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LKUser, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + var u LKUser + var hash string + if err := db.QueryRowContext(cctx, `SELECT id, login, password_hash, contract_number, + COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, + COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''), + COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer + FROM lk.users WHERE login=$1`, login).Scan(&u.ID, &u.Login, &hash, &u.Contract, + &u.FullName, &u.Email, &u.Phone, &u.Active, + &u.LastName, &u.FirstName, &u.MiddleName, + &u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil { + return nil, errLKAuth // не раскрываем, что логина нет + } + if !u.Active { + return nil, errors.New("учётная запись заблокирована") + } + if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { + return nil, errLKAuth + } + _, _ = db.ExecContext(cctx, "UPDATE lk.users SET last_login_at=now() WHERE id=$1", u.ID) // отметка входа, не критично + return &u, nil +} + +// lkListUsers — список пользователей ЛК (для админки). +func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + rows, err := db.QueryContext(cctx, `SELECT id, login, contract_number, + COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, is_admin, can_transfer + FROM lk.users ORDER BY login`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKUser + for rows.Next() { + var u LKUser + if err := rows.Scan(&u.ID, &u.Login, &u.Contract, &u.FullName, &u.Email, &u.Phone, &u.Active, &u.IsAdmin, &u.CanTransfer); err != nil { + return out, err + } + out = append(out, u) + } + return out, rows.Err() +} diff --git a/internal/lkgateway/m2mcodes.go b/internal/lkgateway/m2mcodes.go new file mode 100644 index 0000000..04ce4f3 --- /dev/null +++ b/internal/lkgateway/m2mcodes.go @@ -0,0 +1,172 @@ +package lkgateway + +import ( + "html" + "strings" +) + +// m2mCodeInfo — запись справочника кодов M2M: код, краткое название и развёрнутое +// пояснение простым языком (когда и почему возникает). +type m2mCodeInfo struct { + Code, Short, Full string +} + +// m2mRejectCatalog — коды ОТКАЗА зачисляющей (принимающей) стороны. Приходят в +// решении M2MTransferDecision по конкретной бумаге. +var m2mRejectCatalog = []m2mCodeInfo{ + {"M2M01", "Ценная бумага не допускается к переводам M2M", + "Бумагу нельзя переводить через сервис M2M. Типичный случай — иностранный выпуск (ISIN не начинается с «RU»): сервис МОСТ такие бумаги между депозитариями не пропускает."}, + {"M2M02", "Не может быть определён счёт зачисления", + "Зачисляющий депозитарий не смог определить, на какой счёт депо зачислять бумаги: клиент не указал счёт по умолчанию, а он для операции необходим."}, + {"M2M03", "Счёт депо зачисления не торговый", + "Указанный счёт зачисления не является торговым — зачисление бумаг на него невозможно."}, + {"M2M04", "Инвестор не найден", + "У зачисляющего депозитария нет такого клиента (владельца бумаг): некому зачислять. Возникает, если инвестор не заведён у принимающей стороны."}, + {"M2M05", "Бумага не обслуживается зачисляющим депозитарием", + "Зачисляющий депозитарий не оказывает услуги по учёту прав на данную ценную бумагу (не работает с этим выпуском)."}, + {"M2M06", "Приостановление операций по счёту зачисления", + "По счёту депо зачисления действует приостановка/блокировка операций — зачислить бумаги нельзя."}, + {"M2M07", "Отказ принимающей стороны", + "Общий отказ зачисляющего депозитария. В нашей системе применяется также для повторного (дублирующего) перевода — тот же инвестор и бумаги уже принимались ранее."}, + {"M2M08", "Операция нарушит законодательство РФ", + "Проведение перевода приведёт к нарушению закона: например, бумага предназначена только для квалифицированных инвесторов, а инвестор не квалифицирован."}, + {"M2M09", "Отказ принимающей стороны (доп.)", + "Дополнительный код отказа зачисляющей стороны (используется в тест-сценариях)."}, +} + +// m2mServiceCatalog — коды сервиса МОСТ (ошибки/информирование). Приходят в ответе +// сервиса M2MTransferResponse. +var m2mServiceCatalog = []m2mCodeInfo{ + {"M2M10", "Получено невалидное сообщение", + "Формат или структура запроса не прошли проверку сервиса МОСТ (сообщение не соответствует схеме)."}, + {"M2M14", "Списывающий не в справочнике участников M2M", + "Отправитель (списывающий депозитарий) не зарегистрирован в справочнике участников сервиса M2M."}, + {"M2M15", "Зачисляющий не в справочнике участников M2M", + "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников сервиса M2M."}, + {"M2M17", "Неверно указан ИНН списывающего депозитария", + "ИНН передающей (списывающей) стороны указан неверно или не совпадает со справочником."}, + {"M2M18", "Неверно указан ИНН зачисляющего депозитария", + "ИНН принимающей (зачисляющей) стороны указан неверно или не совпадает со справочником."}, + {"M2M19", "Недопустимое место расчётов", + "Указанные депонент / счёт депо / раздел / расчётный депозитарий не входят в допустимые для перевода M2M."}, + {"M2M30", "Неверно указан код НРД ценной бумаги", + "Код НРД ценной бумаги указан неверно (не найден в справочнике инструментов)."}, + {"M2M31", "Ответ по этому GUID уже обработан", + "Ответ на запрос на перевод M2M с указанным GUID уже был обработан ранее — повторная (дублирующая) отправка ответа/решения."}, +} + +// m2mSpravkaHTML — справка по кодам ошибок/отказа M2M простым языком (для страницы +// и для вложения в пакет отчётов контрагенту). +func m2mSpravkaHTML(standalone bool) []byte { + esc := html.EscapeString + var b strings.Builder + if standalone { + b.WriteString(`Справка по кодам M2M`) + } + b.WriteString(`

    Справка по кодам ошибок и отказов M2M (MOEX МОСТ)

    `) + b.WriteString(`

    Коды, которыми сервис МОСТ и зачисляющий депозитарий сообщают о причине отказа или ошибки при переводе ценных бумаг между депозитариями.

    `) + + section := func(title, note string, cat []m2mCodeInfo) { + b.WriteString(`

    ` + title + `

    `) + if note != "" { + b.WriteString(`

    ` + note + `

    `) + } + b.WriteString(``) + for _, ci := range cat { + b.WriteString(``) + } + b.WriteString(`
    КодКраткоЧто это значит
    ` + esc(ci.Code) + `` + esc(ci.Short) + `` + esc(ci.Full) + `
    `) + } + section("Отказ зачисляющей (принимающей) стороны", + "Приходят в решении M2MTransferDecision по конкретной ценной бумаге — принимающий депозитарий отказал в зачислении.", + m2mRejectCatalog) + section("Ошибки и информирование сервиса МОСТ", + "Приходят в ответе сервиса M2MTransferResponse — сервис отклонил или прокомментировал запрос.", + m2mServiceCatalog) + b.WriteString(`

    Прочие коды сервиса (M2M11–M2M13, M2M16, M2M20–M2M29, M2M32–M2M43) определяются Спецификацией электронных документов НРД (Приложение 5) и в данной справке не расшифрованы.

    `) + if standalone { + b.WriteString(``) + } + return []byte(b.String()) +} + +// m2mCodeTexts — человекочитаемые названия кодов ошибок/отказа M2M НРД. + +// m2mCodeTexts — человекочитаемые названия кодов ошибок/отказа M2M НРД. +// Источники: ответы сервиса МОСТ (Text), инструкция по тестированию с роботом +// (Приложение 5, набор данных об ошибках). M2M01–M2M09 — тестовые коды отказа +// робота (имитация отказа принимающей стороны). +var m2mCodeTexts = map[string]string{ + "M2M01": "Ценная бумага не допускается к переводам M2M", + "M2M02": "Не может быть определён счёт для зачисления ценных бумаг", + "M2M03": "Счёт депо зачисления не торговый", + "M2M04": "Инвестор не найден", + "M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на ценную бумагу", + "M2M06": "Приостановление операций по счёту депо зачисления", + "M2M07": "Отказ принимающей стороны (тест 07)", + "M2M08": "Проведение операции приведёт к нарушению законодательства РФ", + "M2M09": "Отказ принимающей стороны (тест 09)", + "M2M10": "Получено невалидное сообщение", + "M2M14": "Отправитель не зарегистрирован в справочнике участников M2M", + "M2M15": "Получатель (зачисляющий депозитарий) не зарегистрирован в справочнике участников M2M", // предположительно (пара к M2M14); уточнить по Спецификации ЭД + "M2M17": "Неверно указан ИНН списывающего депозитария", + "M2M18": "Неверно указан ИНН зачисляющего депозитария", // предположительно (по соседним кодам); уточнить по Спецификации ЭД + "M2M19": "Недопустимое место расчётов", + "M2M30": "Неверно указан код НРД ценной бумаги", + // Внутренние причины отказа сверки списания (не коды НРД): недостаточно бумаг + // на торговом счёте и неверное место хранения в запросе. + "QTY": "Недостаточно бумаг на торговом счёте депо", + "PLACE": "Неверное место хранения (не наш торговый счёт/раздел в НРД)", +} + +// m2mCodeText возвращает только человеческий текст кода (без самого кода). +// Для неизвестного кода — пустую строку. +func m2mCodeText(code string) string { + return m2mCodeTexts[strings.ToUpper(strings.TrimSpace(code))] +} + +// rejectCodesHuman рендерит коды отказа как «M2M01 (словесное описание)» через +// «; ». Для отчёта регулятору — чтобы рядом с номером кода была расшифровка. +func rejectCodesHuman(codes []string) string { + var parts []string + for _, c := range codes { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if t := m2mCodeText(c); t != "" { + parts = append(parts, c+" ("+t+")") + } else { + parts = append(parts, c) + } + } + return strings.Join(parts, "; ") +} + +// m2mCodeLabel возвращает «Человеческий текст (M2Mxx)». Для неизвестного кода — +// сам код. Используется в шаблонах для понятного отображения кодов. +func m2mCodeLabel(code string) string { + code = strings.TrimSpace(code) + if code == "" { + return "" + } + if t := m2mCodeText(code); t != "" { + return t + " (" + code + ")" + } + return code +} + +// m2mRejectOptions — варианты кодов отказа для формы решения по входящим +// (в порядке полезности). Каждый — {code, label}. +func m2mRejectOptions() []struct{ Code, Label string } { + codes := []string{"M2M05", "M2M01", "M2M02", "M2M03", "M2M04", "M2M06", "M2M07", "M2M08", "M2M09"} + out := make([]struct{ Code, Label string }, 0, len(codes)) + for _, c := range codes { + out = append(out, struct{ Code, Label string }{c, m2mCodeLabel(c)}) + } + return out +} diff --git a/internal/lkgateway/m2mplan_export.go b/internal/lkgateway/m2mplan_export.go new file mode 100644 index 0000000..00d0d4d --- /dev/null +++ b/internal/lkgateway/m2mplan_export.go @@ -0,0 +1,530 @@ +package lkgateway + +// m2mplan_export.go — план тестирования M2M для команды контрагента: самостоятельный +// HTML-документ (открывается в браузере, печатается, пересылается письмом). Строится из +// того же тест-плана, что и страница /admin/m2m-testing: кейсы, тестовые клиенты, бумаги, +// ожидаемые коды. GET /admin/m2m-testing/plan.html[?date=16.09.2026][&inline=1] +// +// Документ интерактивный: контрагент отмечает итог и комментарий по каждому кейсу, подготовку и +// контакты. Отметки сохраняются в его браузере (localStorage), «Скачать файл с отметками» отдаёт +// тот же HTML с отметками внутри ( + + +{{define "mark"}}
    {{end}}`)) diff --git a/internal/lkgateway/m2mtest.go b/internal/lkgateway/m2mtest.go new file mode 100644 index 0000000..4aaf959 --- /dev/null +++ b/internal/lkgateway/m2mtest.go @@ -0,0 +1,892 @@ +package lkgateway + +// m2mtest.go — интерактивный блок «Тестирование M2M» с реальным контрагентом +// (МБ/НРД). Мы — Контрагент 2. Страница /admin/m2m-testing: +// • список всех кейсов из «Сценарий тестирования (приложение 2)» с визуальным +// статусом (не проведён / в процессе / успех / провал); +// • по кейсам, где мы Списывающий, — кнопка «Инициировать отправку» (шлём +// исходящий M2M контрагенту через НРД); +// • по кейсам, где мы Зачисляющий, — подсказка отвечать на «Входящих» (с кодом +// отказа для негативных кейсов). +// Статусы и код контрагента хранятся в /var/lib/bj/.bj/m2m-testplan.json. + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +const m2mTestPlanFile = "/var/lib/bj/.bj/m2m-testplan.json" + +// m2mSec — бумага в кейсе. +type m2mSec struct { + ISIN, Code, Qty string + Frac bool // пай — количество в дробном поле +} + +// m2mClient — тестовый клиент (физлицо). SeedID — id в сид-стороне, по которому +// EnrichRequest соберёт СОГЛАСОВАННЫЕ ФИО+документ+счёт (реальный депозитарий). +type m2mClient struct{ Last, First, Middle, DocSeries, DocNumber, SeedID string } + +var m2mTestClients = map[string]m2mClient{ + "К1": {"Мостовиков", "Арсений", "Тимурович", "4519", "736284", seedClientK1}, + "К2": {"Баранкин", "Сергей", "Сергеевич", "3763", "290089", seedClientK2}, +} + +// эталонные бумаги теста (кол-во — как в сценарии; для нескольких выпусков 10/5/20) +var ( + sSBER = func(q string) m2mSec { return m2mSec{"RU0009029540", "SBER/03", q, false} } + sPAI = func(q string) m2mSec { return m2mSec{"RU000A0JUR61", "RU000A0JUR61", q, true} } + sVTB = func(q string) m2mSec { return m2mSec{"RU000A102QL3", "RU000A102QL3", q, false} } + sGAZP = func(q string) m2mSec { return m2mSec{"RU0007661625", "RU0007661625", q, false} } + sMINF = func(q string) m2mSec { return m2mSec{"XS0088543193", "XS0088543193", q, false} } +) + +// m2mSecName — наименования тест-бумаг (для превью отправляемых данных). +var m2mSecName = map[string]string{ + "RU0009029540": "Акции об. ПАО Сбербанк", + "RU000A0JUR61": "Пай ИПИФ «КОНСЕРВАТОРия»", + "RU000A102QL3": "Облигация суборд. ВТБ (СУБ-Т1-2)", + "RU0007661625": "Акции об. ПАО «Газпром»", + "XS0088543193": "Минфин РФ (USD)", +} + +// m2mCaseDefect — какой реквизит намеренно «неправильный» в кейсе и почему. +// field: "" (позитив, всё верно) | "security" | "account" | "investor" | "other". +func m2mCaseDefect(c m2mCase) (field, desc string) { + switch c.Expect { + case "": + return "", "" + case "M2M01": + return "security", "Бумага не допускается к M2M (не-российский ISIN) — сервис МОСТ отклонит запрос." + case "M2M02": + return "account", "Счёт зачисления не может быть определён зачисляющим — отклонение." + case "M2M03": + return "account", "Счёт депо зачисления не торговый — зачисляющий отклонит." + case "M2M04": + return "investor", "Инвестор (Клиент 2, Баранкин) не найден у зачисляющего депозитария — отклонение." + case "M2M05": + return "security", "Зачисляющий не обслуживает бумагу (Газпром) — отклонение." + case "M2M06": + return "account", "Приостановление операций по счёту депо зачисления — отклонение." + case "M2M08": + return "security", "Бумага для квалинвесторов — операция нарушит закон РФ, отклонение." + } + return "other", "Ожидается ответ сервиса МОСТ с кодом " + c.Expect + " (см. план)." +} + +// m2mSecBad — является ли конкретная бумага той самой «неправильной» в кейсе. +func m2mSecBad(c m2mCase, s m2mSec) bool { + switch c.Expect { + case "M2M01": + return !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(s.ISIN)), "RU") + case "M2M05": + return s.ISIN == "RU0007661625" + case "M2M08": + return true + } + return false +} + +// m2mCase — один кейс тест-плана. +type m2mCase struct { + ID string + Scenario string // "1" | "1-СПБ" | "2" + Debit string // Списывающий + Credit string // Зачисляющий + Client string // "К1"|"К2" + SettleDeb string // "НРД"|"СПБ" + SettleCred string + Secs []m2mSec + Expect string // код отказа/информирования или "" (успех) + Note string +} + +// m2mOurRole — наша роль в кейсе (мы = Контрагент 2). +func m2mOurRole(c m2mCase) string { + switch "Контрагент 2" { + case c.Debit: + return "Списывающий (мы)" + case c.Credit: + return "Зачисляющий (мы)" + } + return "—" +} + +// m2mWeInitiate — по кейсу инициируем отправку мы (мы Списывающий). +func m2mWeInitiate(c m2mCase) bool { return c.Debit == "Контрагент 2" } + +// m2mCases — весь тест-план (Приложение 2). +var m2mCases = buildM2MCases() + +func buildM2MCases() []m2mCase { + K1, K2 := "Контрагент 1", "Контрагент 2" + var cs []m2mCase + add := func(id, deb, cred, cl, sd, sc string, secs []m2mSec, exp, note string) { + cs = append(cs, m2mCase{id, curScenario, deb, cred, cl, sd, sc, secs, exp, note}) + } + // --- Сценарий 1: успешные + отказы Зачисляющего --- + curScenario = "1" + add("1", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "", "Успех, 1 выпуск") + add("2", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "", "Успех, 1 выпуск") + add("3", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sPAI("5"), sVTB("20")}, "", "Успех, 3 выпуска") + add("4", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sPAI("5"), sVTB("20")}, "", "Успех, 3 выпуска") + add("5", K1, K2, "К1", "НРД", "НРД", []m2mSec{sMINF("1")}, "M2M01", "ЦБ не допускается к M2M (не-RU)") + add("6", K2, K1, "К1", "НРД", "НРД", []m2mSec{sMINF("1")}, "M2M01", "ЦБ не допускается к M2M (не-RU)") + add("7", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M02", "Не определён счёт зачисления") + add("8", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M02", "Не определён счёт зачисления") + add("9", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M03", "Счёт депо зачисления не торговый") + add("10", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M03", "Счёт депо зачисления не торговый") + add("11", K1, K2, "К2", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M04", "Инвестор не найден (Клиент 2)") + add("12", K2, K1, "К2", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M04", "Инвестор не найден (Клиент 2)") + add("13", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sGAZP("5")}, "M2M05", "Не обслуживаем бумагу (Газпром)") + add("14", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sGAZP("5")}, "M2M05", "Не обслуживаем бумагу (Газпром)") + add("15", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M06", "Приостановление операций по счёту") + add("16", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M06", "Приостановление операций по счёту") + add("17", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M08", "Нарушение закона РФ (квалинвестор)") + add("18", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M08", "Нарушение закона РФ (квалинвестор)") + // --- Сценарий 1 (СПБ): разные расчётные депозитарии --- + curScenario = "1-СПБ" + add("СПБ-9", K1, K2, "К1", "СПБ", "НРД", []m2mSec{sSBER("10")}, "", "СПБ→НРД") + add("СПБ-10", K2, K1, "К1", "НРД", "СПБ", []m2mSec{sSBER("10")}, "", "НРД→СПБ") + add("СПБ-11", K1, K2, "К1", "НРД", "СПБ", []m2mSec{sSBER("10")}, "", "НРД→СПБ") + add("СПБ-12", K2, K1, "К1", "СПБ", "НРД", []m2mSec{sSBER("10")}, "", "СПБ→НРД") + add("СПБ-13", K1, K2, "К1", "СПБ", "СПБ", []m2mSec{sSBER("10")}, "", "СПБ→СПБ") + add("СПБ-14", K2, K1, "К2", "СПБ", "СПБ", []m2mSec{sSBER("10")}, "", "СПБ→СПБ, Клиент 2") + // --- Сценарий 2: коды ошибок/информирования сервиса МОСТ (чек-лист) --- + curScenario = "2" + add("2-ERR-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M10-15,17-19,21-24,26-43", "Как Списывающий: ERROR на M2MTransferRequest") + add("2-ERR-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M10-15,17-19,21-24,26-43", "Как Зачисляющий: ERROR на M2MTransferDecision") + add("2-INFO16-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M16", "INFO: блок «Информация от сервиса МОСТ» — Списывающий") + add("2-INFO16-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M16", "INFO: блок «Информация от сервиса МОСТ» — Зачисляющий") + add("2-INFO-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M20,25,41", "INFO с блоком «Информация от сервиса МОСТ» — Списывающий") + add("2-INFO-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M20,25,41", "INFO с блоком «Информация от сервиса МОСТ» — Зачисляющий") + add("2-FORM", K2, K1, "К1", "НРД", "НРД", nil, "M2M37-40", "Анкета участника (M2MTransferParticipantForm): ERROR") + return cs +} + +var curScenario string // временная переменная-помощник для buildM2MCases + +// ---- хранилище статусов ---- + +type m2mCaseState struct { + Status string `json:"status"` // "" | in_progress | passed | failed + Note string `json:"note,omitempty"` + GUID string `json:"guid,omitempty"` + Updated time.Time `json:"updated,omitempty"` +} + +type m2mPlan struct { + CounterpartyName string `json:"counterparty_name"` // напр. «Банк ЗЕНИТ» + CounterpartyCode string `json:"counterparty_code"` // код Контрагента 1 + CounterpartyINN string `json:"counterparty_inn"` // ИНН депозитария контрагента + OurINN string `json:"our_inn"` // ИНН нашего депозитария + States map[string]m2mCaseState `json:"states"` +} + +// m2mExcluded — кейсы, которые в этом прогоне НЕ тестируем (по договорённости +// с контрагентом). Отображаются серым, в прогресс не входят. +var m2mExcluded = map[string]bool{} // в прогоне с Банком ЗЕНИТ (31.08.2026) исключались 3 и 4 + +var m2mMu sync.Mutex + +func m2mLoadPlan() m2mPlan { + p := m2mPlan{States: map[string]m2mCaseState{}} + b, err := os.ReadFile(m2mTestPlanFile) + if err == nil { + _ = json.Unmarshal(b, &p) + } + if p.States == nil { + p.States = map[string]m2mCaseState{} + } + return p +} + +func m2mSavePlan(p m2mPlan) error { + b, _ := json.MarshalIndent(p, "", " ") + tmp := m2mTestPlanFile + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, m2mTestPlanFile) +} + +// ---- HTTP ---- + +// registerM2MTest вешает маршруты интерактивного тест-плана. +func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) { + mux.HandleFunc("/admin/m2m-testing", func(w http.ResponseWriter, r *http.Request) { + m2mMu.Lock() + plan := m2mLoadPlan() + m2mMu.Unlock() + + type rowVM struct { + m2mCase + OurRole string + WeInitiate bool + SecList string + State m2mCaseState + StatusClass string + Live string // авто-исход из НРД: "Ждём ответ" / "НРД: confirmed" / "НРД: rejected (M2M02)" + Excluded bool + Desc string // описание кейса для подсказки при наведении + } + ctx := r.Context() + planChanged := false + var rows []rowVM + var passed, failed, prog, total, excluded int + for _, c := range m2mCases { + var secparts []string + for _, s := range c.Secs { + secparts = append(secparts, s.Code+" ×"+s.Qty) + } + st := plan.States[c.ID] + live := "" + if m2mExcluded[c.ID] || c.Scenario == "1-СПБ" { + excluded++ + rows = append(rows, rowVM{ + m2mCase: c, OurRole: m2mOurRole(c), WeInitiate: m2mWeInitiate(c), + SecList: strings.Join(secparts, ", "), State: st, StatusClass: "excluded", Excluded: true, + Desc: m2mCaseDesc(c), + }) + continue + } + // Автоподтягивание по GUID (кейсы, где мы инициировали отправку). + if st.GUID != "" { + if outcome, codes, terminal, ok := h.m2mLiveOutcome(ctx, st.GUID); ok { + if terminal { + live = "НРД: " + outcome + if len(codes) > 0 { + live += " (" + strings.Join(codes, ",") + ")" + } + // авто-вердикт против ожидаемого — только если не отмечено вручную + if st.Status == "" || st.Status == "in_progress" { + want := autoVerdict(c.Expect, outcome, codes) + if st.Status != want { + st.Status = want + st.Note = "Авто из НРД: " + live + st.Updated = time.Now() + plan.States[c.ID] = st + planChanged = true + } + } + } else { + live = "Ждём ответ НРД" + if st.Status == "" { + st.Status = "in_progress" + plan.States[c.ID] = st + planChanged = true + } + } + } + } + total++ + switch st.Status { + case "passed": + passed++ + case "failed": + failed++ + case "in_progress": + prog++ + } + rows = append(rows, rowVM{ + m2mCase: c, OurRole: m2mOurRole(c), WeInitiate: m2mWeInitiate(c), + SecList: strings.Join(secparts, ", "), State: st, StatusClass: st.Status, Live: live, + Desc: m2mCaseDesc(c), + }) + } + if planChanged { + m2mMu.Lock() + // перечитываем и мержим, чтобы не затереть параллельные ручные правки + cur := m2mLoadPlan() + for id, s := range plan.States { + if s.GUID != "" { // авто трогает только инициированные кейсы + cur.States[id] = s + } + } + _ = m2mSavePlan(cur) + m2mMu.Unlock() + } + // Наш депозитарий — код из настроек НРД, ИНН/имя тянем из справочника. + ourCode := h.rc.Snapshot().NSD.DeponentCode + ourINN := plan.OurINN + if ourINN == "" { + ourINN = m2mINNByCode(ourCode) + } + data := struct { + page + Rows []rowVM + CounterpartyName string + CounterpartyCode string + CounterpartyINN string + OurCode string + OurName string + OurINN string + Passed, Failed, Prog, Total, Excluded int + Pending int + }{ + page: nowPage("Тестирование M2M", "m2mtest"), Rows: rows, + CounterpartyName: plan.CounterpartyName, CounterpartyCode: plan.CounterpartyCode, + CounterpartyINN: plan.CounterpartyINN, + OurCode: ourCode, OurName: m2mPartyName(ourCode), OurINN: ourINN, + Passed: passed, Failed: failed, Prog: prog, Total: total, Excluded: excluded, + Pending: total - passed - failed - prog - excluded, + } + render(w, a.m2mtest, data) + }) + + // Превью отправляемых данных с подсветкой: 🟢 корректно, 🔴 намеренно неверно. + mux.HandleFunc("/admin/m2m-testing/preview", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.URL.Query().Get("id")) + var c *m2mCase + for i := range m2mCases { + if id != "" && m2mCases[i].ID == id { + c = &m2mCases[i] + break + } + } + if c == nil { + // ВАЖНО: один чистый редирект на план (без setupFlash, который вернул бы + // на Referer=/preview и создал петлю редиректов → «страница недоступна»). + http.Redirect(w, r, "/admin/m2m-testing?flash="+ + url.QueryEscape("Кейс не выбран или не найден — выберите кейс в плане и нажмите «Инициировать»."), + http.StatusSeeOther) + return + } + m2mMu.Lock() + plan := m2mLoadPlan() + m2mMu.Unlock() + cl := m2mTestClients[c.Client] + st := plan.States[c.ID] + alreadySent := st.GUID != "" // по кейсу уже уходил запрос (защита от дубля) + nsd := h.rc.Snapshot().NSD + defField, defDesc := m2mCaseDefect(*c) + + type pvSec struct { + ISIN, Code, Name, Qty string + Bad bool + } + var secs []pvSec + for _, s := range c.Secs { + secs = append(secs, pvSec{ + ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: s.Qty, + Bad: m2mSecBad(*c, s), + }) + } + ourCode := nsd.DeponentCode + ourINN := plan.OurINN + if ourINN == "" { + ourINN = m2mINNByCode(ourCode) + } + data := struct { + page + Case m2mCase + WeInitiate bool + Positive bool + DefectField string + DefectDesc string + ClientKey string + ClientFIO string + ClientDUL string + ClientBad bool + OurCode string + OurName string + OurINN string + OurAccount string + CpCode string + CpName string + CpINN string + Secs []pvSec + AlreadySent bool + SentGUID string + SentWhen string + Depo depoResult // сверка списания с DEPO (эмуляция; только для кейсов, где мы списывающий) + }{ + page: nowPage("Проверка данных перед отправкой", "m2mtest"), + Case: *c, + AlreadySent: alreadySent, + SentGUID: st.GUID, + SentWhen: st.Updated.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05"), + Depo: func() depoResult { + if !m2mWeInitiate(*c) { + return depoResult{} + } + var ss []depoOutSec + for _, s := range c.Secs { + ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: s.Qty}) + } + return runDepoCheckOut(cl.Last, cl.First, cl.Middle, cl.DocSeries, cl.DocNumber, ss, nsd.AccountID, nsd.SectionID) + }(), + WeInitiate: m2mWeInitiate(*c), + Positive: defField == "", + DefectField: defField, + DefectDesc: defDesc, + ClientKey: c.Client, + ClientFIO: strings.TrimSpace(cl.Last + " " + cl.First + " " + cl.Middle), + ClientDUL: "паспорт РФ " + cl.DocSeries + " № " + cl.DocNumber, + ClientBad: defField == "investor", + OurCode: ourCode, + OurName: m2mPartyName(ourCode), + OurINN: ourINN, + OurAccount: nsd.DeponentCode + " / счёт " + nsd.AccountID + " / раздел " + nsd.SectionID, + CpCode: plan.CounterpartyCode, + CpName: plan.CounterpartyName, + CpINN: plan.CounterpartyINN, + Secs: secs, + } + render(w, a.m2mpreview, data) + }) + + // сохранение кода/ИНН контрагента + mux.HandleFunc("/admin/m2m-testing/counterparty", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + m2mMu.Lock() + plan := m2mLoadPlan() + plan.CounterpartyName = strings.TrimSpace(r.FormValue("counterparty_name")) + plan.CounterpartyCode = strings.TrimSpace(r.FormValue("counterparty_code")) + plan.CounterpartyINN = strings.TrimSpace(r.FormValue("counterparty_inn")) + plan.OurINN = strings.TrimSpace(r.FormValue("our_inn")) + _ = m2mSavePlan(plan) + m2mMu.Unlock() + http.Redirect(w, r, "/admin/m2m-testing", http.StatusSeeOther) + }) + + // установка статуса кейса + mux.HandleFunc("/admin/m2m-testing/status", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + id := strings.TrimSpace(r.FormValue("id")) + status := strings.TrimSpace(r.FormValue("status")) + note := strings.TrimSpace(r.FormValue("note")) + m2mMu.Lock() + plan := m2mLoadPlan() + st := plan.States[id] + switch status { + case "reset": + st.Status = "" + case "": + // статус не меняем (например, сохранение только заметки) + default: + st.Status = status + } + if _, ok := r.Form["note"]; ok { + st.Note = note + } + st.Updated = time.Now() + plan.States[id] = st + _ = m2mSavePlan(plan) + m2mMu.Unlock() + // AJAX → 204, обычная форма → редирект + if r.Header.Get("X-Requested-With") == "fetch" { + w.WriteHeader(http.StatusNoContent) + return + } + http.Redirect(w, r, "/admin/m2m-testing", http.StatusSeeOther) + }) + + // инициировать отправку исходящего M2M по кейсу (мы — Списывающий) + // Единая выгрузка отчётов по пройденным кейсам (для отправки контрагенту). + mux.HandleFunc("/admin/m2m-testing/depo-replay", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + report := depoReplayHistory(ctx, h.rc, h.svc, r.FormValue("cp")) + http.Redirect(w, r, "/admin/m2m-testing?flash="+urlQ("Выгрузка истории в DEPO: "+report), http.StatusSeeOther) + }) + // План тестирования для команды контрагента (самостоятельный HTML). + mux.HandleFunc("/admin/m2m-testing/plan.html", func(w http.ResponseWriter, r *http.Request) { + writeM2MPlanHTML(w, r, h) + }) + mux.HandleFunc("/admin/m2m-testing/report-bundle.zip", func(w http.ResponseWriter, r *http.Request) { + writeM2MReportBundle(w, r, h) + }) + + // Эталон ответов для программиста Fancy (входящие от контрагента + наши решения). + mux.HandleFunc("/admin/m2m-testing/fancy-bundle.zip", func(w http.ResponseWriter, r *http.Request) { + writeFancyBundle(w, r, h) + }) + + // Наглядное описание процесса M2M (для программиста) — с файлами каждого шага. + mux.HandleFunc("/admin/m2m-testing/process", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(fancyProcessHTML(buildProcessExample(r, h))) + }) + + // Справка по кодам ошибок/отказов M2M (простым языком). + mux.HandleFunc("/admin/m2m-testing/spravka", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(m2mSpravkaHTML(true)) + }) + + mux.HandleFunc("/admin/m2m-testing/initiate", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + id := strings.TrimSpace(r.FormValue("id")) + var cs *m2mCase + for i := range m2mCases { + if m2mCases[i].ID == id { + cs = &m2mCases[i] + break + } + } + // ВАЖНО: один явный редирект на ПЛАН (форма постится с /preview, поэтому + // setupFlash по Referer вернул бы на /preview без id → петля редиректов). + back := func(msg string) { + http.Redirect(w, r, "/admin/m2m-testing?flash="+url.QueryEscape(msg), http.StatusSeeOther) + } + if cs == nil { + back("M2M-тест: кейс " + id + " не найден") + return + } + m2mMu.Lock() + plan := m2mLoadPlan() + m2mMu.Unlock() + if strings.TrimSpace(plan.CounterpartyCode) == "" { + back("Сначала укажите код участника контрагента (кнопка «Контрагент»)") + return + } + guid, err := h.m2mInitiate(*cs, plan) + m2mMu.Lock() + plan = m2mLoadPlan() + st := plan.States[id] + var msg string + if err != nil { + st.Status = "failed" + st.Note = "Отправка не удалась: " + err.Error() + msg = "Кейс " + id + ": отправка не удалась: " + err.Error() + } else { + st.Status = "in_progress" + st.GUID = guid + st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + "). Ждём ответ." + msg = "Кейс " + id + ": исходящий M2M отправлен (GUID " + guid + "). Ответ придёт во «Входящие»." + } + st.Updated = time.Now() + plan.States[id] = st + _ = m2mSavePlan(plan) + m2mMu.Unlock() + back(msg) + }) +} + +// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту. +func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) { + if len(c.Secs) == 0 { + return "", fmt.Errorf("в кейсе %s нет бумаг для отправки (это кейс сценария 2 — формируется вручную)", c.ID) + } + nsd := h.rc.Snapshot().NSD + depCode, accID, sectID := nsd.DeponentCode, nsd.AccountID, nsd.SectionID + cl := m2mTestClients[c.Client] + + secs := make([]ClaimSec, 0, len(c.Secs)) + for _, s := range c.Secs { + qty := Quantity{Whole: uint64(atoiSafe(s.Qty))} + if s.Frac { + qty = Quantity{Fractional: s.Qty} + } + secs = append(secs, ClaimSec{ + SecurityCode: s.Code, + SecurityDetails: SecurityDetails{ISIN: s.ISIN}, + Quantity: qty, + SettlementAccounts: []SettlementAccount{{ + SettlementRequisitesINN: m2mSettleINN(c.SettleCred), + SettlementLocation: SettlementLocation{ + DeponentCode: depCode, AccountID: accID, SectionID: sectID, + }, + }}, + }) + } + ourINN := plan.OurINN + if ourINN == "" { + ourINN = "7703807489" // дефолт — уточнить реквизитами нашего депозитария + } + recvINN := plan.CounterpartyINN + if recvINN == "" { + recvINN = "7722061076" + } + req := CreateClaimRequest{ + Investor: Investor{ + ID: cl.SeedID, // согласованная личность из сид-стора (ФИО+документ+счёт) + LastName: cl.Last, FirstName: cl.First, MiddleName: cl.Middle, + Document: Document{DocumentType: "21", Series: cl.DocSeries, Number: cl.DocNumber}, + }, + TransferringDepositoryINN: ourINN, + ReceivingDepositoryINN: recvINN, + CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}}, + Securities: secs, + SignedDocument: "dGVzdC1zaWduYXR1cmU=", + SignatureFormat: "XMLDSig-GOST", + ReceiverCodeOverride: plan.CounterpartyCode, + InvestorDocumentOverride: &Document{DocumentType: "21", Series: cl.DocSeries, Number: cl.DocNumber}, + } + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + created, err := h.svc.CreateClaim(ctx, req) + if err != nil { + return "", err + } + return created.ID, nil +} + +// m2mCodeDesc — расшифровка кодов отказа/информирования для подсказок. +var m2mCodeDesc = map[string]string{ + "M2M01": "Ценная бумага не допускается к переводам M2M (напр. не-российский ISIN). Сервис/зачисляющий отклоняет запрос.", + "M2M02": "Не может быть определён счёт для зачисления ЦБ (клиент не указал счёт по умолчанию, а он требуется). Зачисляющий отклоняет.", + "M2M03": "Счёт депо зачисления не торговый. Зачисляющий отклоняет перевод.", + "M2M04": "Инвестор не найден у зачисляющего депозитария. Отклонение.", + "M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на эту бумагу. Отклонение.", + "M2M06": "Приостановление операций по счёту депо зачисления. Отклонение.", + "M2M08": "Операция приведёт к нарушению законодательства РФ (напр. бумага для квалинвесторов, а инвестор не квалифицирован). Отклонение.", +} + +// m2mCaseDesc строит человекочитаемое описание кейса для подсказки при наведении. +func m2mCaseDesc(c m2mCase) string { + var b strings.Builder + fmt.Fprintf(&b, "Списывает %s, зачисляет %s. Клиент %s.", c.Debit, c.Credit, c.Client) + if len(c.Secs) > 0 { + var ss []string + for _, s := range c.Secs { + ss = append(ss, s.Code+" ×"+s.Qty) + } + fmt.Fprintf(&b, " Бумаги: %s.", strings.Join(ss, ", ")) + } + fmt.Fprintf(&b, " Расчёты: %s→%s.", c.SettleDeb, c.SettleCred) + switch { + case c.Expect == "": + b.WriteString(" Ожидается: УСПЕШНЫЙ перевод — бумаги списываются у списывающего и зачисляются зачисляющему, статус подтверждён.") + case m2mCodeDesc[c.Expect] != "": + fmt.Fprintf(&b, " Ожидается ОТКАЗ (%s): %s", c.Expect, m2mCodeDesc[c.Expect]) + default: + fmt.Fprintf(&b, " Ожидается код сервиса МОСТ %s: %s", c.Expect, c.Note) + } + switch m2mOurRole(c) { + case "Зачисляющий (мы)": + if c.Expect == "" { + b.WriteString(" Наше действие: принять входящий перевод во «Входящих» (подтвердить зачисление).") + } else { + fmt.Fprintf(&b, " Наше действие: во «Входящих» ОТКЛОНИТЬ с кодом %s.", c.Expect) + } + case "Списывающий (мы)": + b.WriteString(" Наше действие: инициировать отправку и ждать решение контрагента.") + } + return b.String() +} + +// ---- справочник участников: код/ИНН → наименование ---- + +var ( + hbMu sync.Mutex + hbByCode map[string]string + hbByINN map[string]string + hbCodeINN map[string]string + hbEntries []HandbookEntry + hbLoadedAt time.Time +) + +func handbookLoad() { + hbMu.Lock() + defer hbMu.Unlock() + if hbByCode != nil && time.Since(hbLoadedAt) < 5*time.Minute { + return + } + byCode, byINN, codeINN := map[string]string{}, map[string]string{}, map[string]string{} + var entries []HandbookEntry + if b, err := os.ReadFile(handbookFile); err == nil { + var doc struct { + Participants []HandbookEntry `json:"participants"` + Places []HandbookEntry `json:"settlement_places"` + } + if json.Unmarshal(b, &doc) == nil { + for _, p := range append(doc.Participants, doc.Places...) { + if p.Code != "" && p.Name != "" { + byCode[p.Code] = p.Name + } + if p.INN != "" && p.Name != "" { + byINN[p.INN] = p.Name + } + if p.Code != "" && p.INN != "" { + codeINN[p.Code] = p.INN + } + if p.Code != "" || p.INN != "" { + entries = append(entries, p) + } + } + } + } + hbByCode, hbByINN, hbCodeINN, hbEntries, hbLoadedAt = byCode, byINN, codeINN, entries, time.Now() +} + +func handbookMaps() (map[string]string, map[string]string) { + handbookLoad() + return hbByCode, hbByINN +} + +// m2mINNByCode — ИНН депозитария по MC-коду участника (из справочника). +func m2mINNByCode(code string) string { + handbookLoad() + hbMu.Lock() + defer hbMu.Unlock() + return hbCodeINN[strings.TrimSpace(code)] +} + +// m2mHandbookJSON — участники справочника для пред-заполнения (dropdown). +func m2mHandbookJSON() template.JS { + handbookLoad() + hbMu.Lock() + entries := hbEntries + hbMu.Unlock() + b, _ := json.Marshal(entries) + return template.JS(b) +} + +// m2mPartyName возвращает наименование организации по MC-коду участника или ИНН +// из справочника M2M (#M2MTH). Пусто, если не найдено. +func m2mPartyName(codeOrINN string) string { + s := strings.TrimSpace(codeOrINN) + if s == "" { + return "" + } + byCode, byINN := handbookMaps() + if n := byCode[s]; n != "" { + return n + } + return byINN[s] +} + +// m2mIncomingHint — подсказка для страницы «Входящие»: что сделать по кейсу. +type m2mIncomingHint struct { + ID string `json:"id"` + Title string `json:"title"` + Action string `json:"action"` // "confirm" | "reject" + Code string `json:"code,omitempty"` + Text string `json:"text"` // человекочитаемая инструкция +} + +// m2mIncomingHintsJSON отдаёт JSON подсказок по кейсам, где мы Зачисляющий +// (получаем входящий и отвечаем). Для дропдауна-подсказки на «Входящих». +func m2mIncomingHintsJSON() template.JS { + var hints []m2mIncomingHint + for _, c := range m2mCases { + if m2mExcluded[c.ID] || c.Scenario == "1-СПБ" { + continue + } + if m2mOurRole(c) != "Зачисляющий (мы)" || len(c.Secs) == 0 { + continue + } + h := m2mIncomingHint{ID: c.ID} + if c.Expect == "" { + h.Title = "Кейс " + c.ID + " — успешное зачисление" + h.Action = "confirm" + h.Text = "ПРИНЯТЬ все бумаги (подтвердить зачисление). Ожидается успешный перевод." + } else { + h.Title = "Кейс " + c.ID + " — отказ " + c.Expect + h.Action = "reject" + h.Code = c.Expect + meaning := m2mCodeDesc[c.Expect] + h.Text = "ОТКЛОНИТЬ с кодом " + c.Expect + " — " + meaning + } + hints = append(hints, h) + } + b, _ := json.Marshal(hints) + return template.JS(b) +} + +// m2mLiveOutcome по GUID возвращает исход НРД: outcome (confirmed/rejected/partial), +// коды отказа, terminal (решение получено), ok (заявка найдена в системе). +func (h *setupHandlers) m2mLiveOutcome(ctx context.Context, guid string) (outcome string, codes []string, terminal, ok bool) { + v, err := h.svc.GetClaim(ctx, guid) + if err != nil { + return "", nil, false, false + } + if v.M2MDecision == nil { + switch v.Status { + case "done", "confirmed": + return "confirmed", nil, true, true + case "rejected", "declined": + return "rejected", nil, true, true + case "partial": + return "partial", nil, true, true + } + return "", nil, false, true // решение ещё не пришло + } + conf, rej := 0, 0 + for _, s := range v.M2MDecision.Securities { + if s.Outcome == "rejected" { + rej++ + codes = append(codes, s.RejectCodes...) + } else { + conf++ + } + } + switch { + case rej == 0: + outcome = "confirmed" + case conf == 0: + outcome = "rejected" + default: + outcome = "partial" + } + return outcome, uniqStrings(codes), true, true +} + +// autoVerdict сравнивает ожидаемое (c.Expect) с фактическим исходом и кодами и +// возвращает "passed"/"failed". +func autoVerdict(expect, outcome string, codes []string) string { + if strings.TrimSpace(expect) == "" { // ожидали успех + if outcome == "confirmed" { + return "passed" + } + return "failed" + } + // ожидали отказ с конкретным кодом + if outcome == "rejected" || outcome == "partial" { + for _, c := range codes { + if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(expect)) { + return "passed" + } + } + if len(codes) == 0 { + return "passed" // отказ есть, кодов не видно — считаем ожидаемым + } + return "failed" // код отказа не совпал с ожидаемым + } + return "failed" // ожидали отказ, а НРД подтвердил +} + +func uniqStrings(in []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range in { + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// m2mSettleINN — ИНН расчётного депозитария по названию площадки из тест-плана +// ("СПБ"/"НРД"). Приводит к коду площадки и делегирует settlementPlaceINN. +func m2mSettleINN(place string) string { + if strings.EqualFold(strings.TrimSpace(place), "СПБ") { + return settlementPlaceINN("spb") + } + return settlementPlaceINN("nrd") +} diff --git a/internal/lkgateway/m2mtest_bundle.go b/internal/lkgateway/m2mtest_bundle.go new file mode 100644 index 0000000..3381e46 --- /dev/null +++ b/internal/lkgateway/m2mtest_bundle.go @@ -0,0 +1,232 @@ +package lkgateway + +// m2mtest_bundle.go — единая выгрузка отчётов по проведённым тестам M2M одним ZIP: +// комплект доказательств по каждому пройденному кейсу (исходящие заявки + входящие +// переводы) плюс сопроводительная HTML-сводка «что провели за сегодня». Удобно +// отправить контрагенту, чтобы он проверил результаты тестирования. + +import ( + "archive/zip" + "bytes" + "fmt" + "html" + "net/http" + "strings" + "time" +) + +// globalIncomingStore — стор входящих переводов для сводной выгрузки (ставится в server.go). +var globalIncomingStore *IncomingStore + +// writeM2MReportBundle собирает ZIP: svodka.html + папка на каждый пройденный кейс. +func writeM2MReportBundle(w http.ResponseWriter, r *http.Request, h *setupHandlers) { + ctx := r.Context() + m2mMu.Lock() + plan := m2mLoadPlan() + m2mMu.Unlock() + + // Входящие по GUID (id заявки == GUID, поэтому исходящие резолвим напрямую GetClaim). + incomingByGUID := map[string]*IncomingTransfer{} + if globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + incomingByGUID[strings.ToUpper(strings.TrimSpace(it.GUID))] = it + } + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + // Сопроводительная сводка. + if f, e := zw.Create("svodka.html"); e == nil { + _, _ = f.Write(m2mBundleCoverHTML(plan)) + } + // Справка по кодам ошибок/отказов — чтобы контрагент понимал коды в отчётах. + if f, e := zw.Create("spravka-kody-oshibok.html"); e == nil { + _, _ = f.Write(m2mSpravkaHTML(true)) + } + + included := 0 + seen := map[string]bool{} + // 1) Исходящие: по каждому инициированному нами кейсу (есть GUID в плане). + for _, c := range m2mCases { + st := plan.States[c.ID] + g := strings.TrimSpace(st.GUID) + gu := strings.ToUpper(g) + if g == "" || seen[gu] { + continue + } + folder := "ishodyashie/kejs-" + m2mSafeName(c.ID) + "/" + if v, err := h.svc.GetClaim(ctx, g); err == nil { // id заявки == GUID + v.Timeline = buildClaimTimeline(ctx, v) + writeClaimBundle(ctx, zw, h.svc, v, folder) + seen[gu] = true + included++ + } else if it := incomingByGUID[gu]; it != nil { + writeIncomingBundleZip(zw, it, folder) + seen[gu] = true + included++ + } + } + // 2) Входящие: все отвеченные переводы от контрагента по тестам (там же MS010). + cpCode := strings.ToUpper(strings.TrimSpace(plan.CounterpartyCode)) + if globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + g := strings.ToUpper(strings.TrimSpace(it.GUID)) + if seen[g] || it.Status != "answered" { + continue + } + if cpCode != "" && strings.ToUpper(it.SenderCode) != cpCode { + continue // только от контрагента, кому шлём пакет + } + writeIncomingBundleZip(zw, it, "vhodyashie/perevod-"+m2mSafeName(it.ID)+"/") + seen[g] = true + included++ + } + } + + if err := zw.Close(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fname := fmt.Sprintf("m2m-testy-%s-%s.zip", m2mSafeName(plan.CounterpartyCode), time.Now().Format("2006-01-02")) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`) + _, _ = w.Write(buf.Bytes()) +} + +// writeIncomingBundleZip пишет комплект по входящему переводу в подпапку prefix +// (аналог exportIncoming): отчёт + request/decision + поручение 16/1 + ответ НРД. +func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string) { + if f, e := zw.Create(prefix + "otchet.html"); e == nil { + _, _ = f.Write(incomingReportHTML(it)) + } + if it.RawXML != "" { + if f, e := zw.Create(prefix + "request.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.RawXML)) + } + } + if it.DecisionXML != "" { + if f, e := zw.Create(prefix + "decision.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.DecisionXML)) + } + } + if it.OrderXML != "" { + if f, e := zw.Create(prefix + "poruchenie-16-1.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(it.OrderXML)) + } + } + for _, rf := range collectOrderResponseRaw(it.OrderContrDID) { + if f, e := zw.Create(prefix + "otvet-nrd/" + rf.Name); e == nil { + _, _ = f.Write(rf.Data) + } + } +} + +// m2mBundleCoverHTML — сопроводительная сводка: что и с каким результатом провели. +func m2mBundleCoverHTML(plan m2mPlan) []byte { + esc := html.EscapeString + var b strings.Builder + b.WriteString(`Сводка тестирования M2M`) + b.WriteString(`
    `) + b.WriteString(`
    ` + moexMostLogoSVG + orgLogoIMG() + `
    `) + b.WriteString(`

    Сводка тестирования переводов ценных бумаг (M2M / MOEX МОСТ)

    `) + b.WriteString(`
    Сформировано: ` + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04") + ` МСК
    `) + + cp := plan.CounterpartyName + if cp == "" { + cp = plan.CounterpartyCode + } + b.WriteString(`

    Контрагент по тестированию: ` + esc(cp) + ``) + if plan.CounterpartyCode != "" { + b.WriteString(` (код ` + esc(plan.CounterpartyCode) + ``) + if plan.CounterpartyINN != "" { + b.WriteString(`, ИНН ` + esc(plan.CounterpartyINN)) + } + b.WriteString(`)`) + } + b.WriteString(`.

    `) + + // Считаем итоги. + var passed, failed, prog int + type row struct { + c m2mCase + st m2mCaseState + } + var rows []row + for _, c := range m2mCases { + st := plan.States[c.ID] + if st.Status == "" && st.GUID == "" { + continue // кейс не запускали + } + switch st.Status { + case "passed": + passed++ + case "failed": + failed++ + case "in_progress": + prog++ + } + rows = append(rows, row{c, st}) + } + b.WriteString(fmt.Sprintf(`

    Итого прогнали: %d · пройдено %d · не прошло %d · в процессе %d.

    `, + len(rows), passed, failed, prog)) + + b.WriteString(`

    Проведённые кейсы

    `) + b.WriteString(``) + for _, rw := range rows { + c, st := rw.c, rw.st + dir := esc(c.Debit) + " → " + esc(c.Credit) + var secs []string + for _, s := range c.Secs { + secs = append(secs, esc(s.Code)+"×"+esc(s.Qty)) + } + exp := "успех" + if c.Expect != "" { + exp = esc(m2mCodeLabel(c.Expect)) + } + var res string + switch st.Status { + case "passed": + res = `✓ пройдено` + case "failed": + res = `✗ не прошло` + case "in_progress": + res = `⏳ в процессе` + default: + res = `—` + } + gt := "" + if st.GUID != "" { + gt = `` + esc(st.GUID) + `` + } + if !st.Updated.IsZero() { + gt += `
    ` + st.Updated.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05") + `
    ` + } + b.WriteString(``) + } + b.WriteString(`
    КейсНаправлениеКлиентБумагиОжидалиИтогGUID / время
    ` + esc(c.ID) + `` + dir + `` + esc(c.Client) + + `` + strings.Join(secs, "
    ") + `
    ` + exp + `` + res + + `` + gt + `
    `) + b.WriteString(`

    По каждому пройденному кейсу в этом архиве — отдельная папка kejs-<номер>/ с отчётом-доказательством (otchet.html) и дословными XML запроса/ответа НРД. Для входящих также приложен ответ НРД (отчёт MS010) в папке otvet-nrd/.

    `) + b.WriteString(``) + return []byte(b.String()) +} + +// m2mSafeName — безопасное имя папки/файла из id кейса или кода. +func m2mSafeName(s string) string { + s = strings.TrimSpace(s) + repl := func(r rune) rune { + switch r { + case '/', '\\', ' ', ':', '*', '?', '"', '<', '>', '|': + return '-' + } + return r + } + return strings.Map(repl, s) +} diff --git a/internal/lkgateway/media.go b/internal/lkgateway/media.go index 7ce1f2c..1f30dd6 100644 --- a/internal/lkgateway/media.go +++ b/internal/lkgateway/media.go @@ -11,7 +11,6 @@ import ( "fmt" "os" "os/exec" - "os/user" "path/filepath" "strings" "time" @@ -50,11 +49,11 @@ type Medium struct { // ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse), // ЛСП (.gdbm) и ключи (vdkeys/*.vdk). type ValidataProfile struct { - Root string `json:"root"` // mountpoint, где найден профиль - PSEFiles []string `json:"pse_files"` // относительные пути до .pse - GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm - KeyFiles []string `json:"key_files"` // относительные пути до .vdk - Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/ + Root string `json:"root"` // mountpoint, где найден профиль + PSEFiles []string `json:"pse_files"` // относительные пути до .pse + GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm + KeyFiles []string `json:"key_files"` // относительные пути до .vdk + Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/ } // KeyContainer — ключевой контейнер: директория с *.key или *.vdk. @@ -69,7 +68,7 @@ type KeyContainer struct { type CertFile struct { Path string `json:"path"` Name string `json:"name"` - Format string `json:"format"` // "cer" | "pem" | "pfx" + Format string `json:"format"` // "cer" | "pem" | "pfx" SubjectCN string `json:"subject_cn"` IssuerCN string `json:"issuer_cn"` Serial string `json:"serial"` @@ -80,75 +79,6 @@ type CertFile struct { ParseError string `json:"parse_error,omitempty"` } -// ScanMedia собирает список всех видимых носителей: USB + распакованные -// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего -// уровня в типичных mount-точках. -func ScanMedia() []Medium { - var out []Medium - out = append(out, scanUSB()...) - out = append(out, listExtractedISOs()...) - return out -} - -// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt. -func scanUSB() []Medium { - u, err := user.Current() - if err != nil { - return nil - } - roots := []string{ - filepath.Join("/run/media", u.Username), - filepath.Join("/media", u.Username), - "/media", - "/mnt", - } - var out []Medium - seen := map[string]bool{} - for _, root := range roots { - entries, err := os.ReadDir(root) - if err != nil { - continue - } - for _, e := range entries { - if !e.IsDir() { - continue - } - mountpoint := filepath.Join(root, e.Name()) - // Не лезем в наши собственные /var/lib/bj/media/iso/*. - if strings.HasPrefix(mountpoint, mediaISODir) { - continue - } - if seen[mountpoint] { - continue - } - seen[mountpoint] = true - out = append(out, scanMountpoint("usb", mountpoint, "")) - } - } - return out -} - -// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/. -func listExtractedISOs() []Medium { - entries, err := os.ReadDir(mediaISODir) - if err != nil { - return nil - } - var out []Medium - for _, e := range entries { - if !e.IsDir() { - continue - } - id := e.Name() - mountpoint := filepath.Join(mediaISODir, id) - source := readISOSource(id) - m := scanMountpoint("iso", mountpoint, source) - m.ID = id - out = append(out, m) - } - return out -} - // scanMountpoint сканирует точку монтирования на 3 уровня вглубь. func scanMountpoint(kind, mountpoint, source string) Medium { m := Medium{ @@ -383,332 +313,6 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) { return m, nil } -// UnmountISO удаляет всё, что относится к загруженному образу: -// - распакованную директорию /var/lib/bj/media/iso//; -// - .src-meta файл с записанным источником; -// - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится -// в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/). -// -// Безопасно только для тех id, что лежат в нашем mediaISODir. -func UnmountISO(id string) error { - if strings.ContainsAny(id, "/.") { - return errors.New("неверный id") - } - dst := filepath.Join(mediaISODir, id) - if !strings.HasPrefix(dst, mediaISODir+"/") { - return errors.New("путь вне media-root") - } - // Сначала забираем путь исходника, потом удаляем .src. - src := readISOSource(id) - if err := os.RemoveAll(dst); err != nil { - return err - } - _ = os.Remove(filepath.Join(mediaISODir, id+".src")) - // Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/, - // удаляем и сам файл .img/.iso. - if src != "" { - abs, _ := filepath.Abs(src) - if strings.HasPrefix(abs, "/var/lib/bj/iso/") { - _ = os.Remove(abs) - } - } - return nil -} - -// ImportKeyContainer копирует контейнер в /var/lib/bj/containers//. -// Возвращает целевой путь. -func ImportKeyContainer(src string) (string, error) { - info, err := os.Stat(src) - if err != nil { - return "", fmt.Errorf("источник: %w", err) - } - if !info.IsDir() { - return "", errors.New("источник не директория") - } - if _, ok := classifyContainer(src); !ok { - return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер") - } - if err := os.MkdirAll(containersDir, 0o755); err != nil { - return "", fmt.Errorf("создать %s: %w", containersDir, err) - } - name := filepath.Base(src) - dst := filepath.Join(containersDir, name) - if _, err := os.Stat(dst); err == nil { - return "", fmt.Errorf("контейнер %q уже импортирован", name) - } - if err := os.MkdirAll(dst, 0o700); err != nil { - return "", fmt.Errorf("создать %s: %w", dst, err) - } - entries, err := os.ReadDir(src) - if err != nil { - return "", err - } - for _, e := range entries { - if e.IsDir() { - continue - } - sp := filepath.Join(src, e.Name()) - dp := filepath.Join(dst, e.Name()) - data, err := os.ReadFile(sp) - if err != nil { - return "", fmt.Errorf("чтение %s: %w", e.Name(), err) - } - if err := os.WriteFile(dp, data, 0o600); err != nil { - return "", fmt.Errorf("запись %s: %w", e.Name(), err) - } - } - return dst, nil -} - -// ImportProfileResult — результат импорта профиля Валидаты. -type ImportProfileResult struct { - Path string // /var/lib/bj/profiles// - Pki1ConfSection string // готовая секция для pki1.conf - ConfWritten bool // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf - ConfWriteError string // если не удалось — причина -} - -const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf" - -// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в -// /var/lib/bj/profiles//, генерирует секцию для pki1.conf и -// пробует дописать её в системный конфиг Валидаты. Имя берётся от -// носителя, если name пуст. Возвращает деталь — что получилось. -func ImportProfile(root, name string) (ImportProfileResult, error) { - if name == "" { - name = filepath.Base(root) - } - if !validProfileName(name) { - return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'") - } - if err := os.MkdirAll(profilesDir, 0o755); err != nil { - return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err) - } - dst := filepath.Join(profilesDir, name) - if _, err := os.Stat(dst); err == nil { - return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name) - } - if err := copyTree(root, dst); err != nil { - _ = os.RemoveAll(dst) - return ImportProfileResult{}, err - } - - // Ищем фактический pse и gdbm внутри импортированной папки — - // обычно spr*/local.pse + spr*/local.gdbm. - psePath, gdbmPath := findProfileFiles(dst) - if psePath == "" { - return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный") - } - - section := buildPki1ConfSection(name, psePath, gdbmPath) - // Сохраняем секцию рядом с профилем — чтобы оператор мог - // посмотреть/перечитать. - _ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"), - []byte(section), 0o644) - - res := ImportProfileResult{ - Path: dst, - Pki1ConfSection: section, - } - // Пробуем дописать в pki1.conf — если файл доступен на запись. - if err := appendToPki1Conf(name, section); err != nil { - res.ConfWriteError = err.Error() - } else { - res.ConfWritten = true - } - return res, nil -} - -func validProfileName(s string) bool { - if s == "" || len(s) > 64 { - return false - } - for _, r := range s { - ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' - if !ok { - return false - } - } - return true -} - -// findProfileFiles ищет .pse и .gdbm внутри директории профиля. -// Возвращает абсолютные пути или пустые строки. -func findProfileFiles(dir string) (psePath, gdbmPath string) { - _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { - if err != nil || info.IsDir() { - return nil - } - lower := strings.ToLower(info.Name()) - if psePath == "" && strings.HasSuffix(lower, ".pse") { - psePath = p - } - if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") { - gdbmPath = p - } - return nil - }) - return -} - -// buildPki1ConfSection формирует блок pki1.conf для нашего профиля. -func buildPki1ConfSection(name, psePath, gdbmPath string) string { - var b strings.Builder - b.WriteString("\n# --- bj-server: профиль " + name + " ---\n") - b.WriteString("local: " + name + "\n") - b.WriteString("pse: pse://signed/" + psePath + "\n") - if gdbmPath != "" { - b.WriteString("localstore: file://" + gdbmPath + "\n") - } - return b.String() -} - -// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс -// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое. -// Дедуп — если в файле уже есть блок с тем же `local: `, не -// пишем повторно. -func appendToPki1Conf(name, section string) error { - existing, err := os.ReadFile(validataPki1Conf) - if err != nil { - return fmt.Errorf("read %s: %w", validataPki1Conf, err) - } - marker := "local: " + name - if strings.Contains(string(existing), marker) { - return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name) - } - f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0) - if err != nil { - return fmt.Errorf("open %s: %w", validataPki1Conf, err) - } - defer f.Close() - if _, err := f.WriteString(section); err != nil { - return fmt.Errorf("write %s: %w", validataPki1Conf, err) - } - return nil -} - -// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий. -// Права на новые директории — 0700, на файлы — 0600 (приватные ключи). -func copyTree(src, dst string) error { - return filepath.Walk(src, func(p string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, p) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - if info.IsDir() { - return os.MkdirAll(target, 0o700) - } - data, err := os.ReadFile(p) - if err != nil { - return err - } - return os.WriteFile(target, data, 0o600) - }) -} - -// DeleteImportedContainer сносит /var/lib/bj/containers//. -func DeleteImportedContainer(name string) error { - if !validProfileName(name) { - return errors.New("неверное имя контейнера") - } - dir := filepath.Join(containersDir, name) - if _, err := os.Stat(dir); err != nil { - return fmt.Errorf("контейнер не найден: %w", err) - } - return os.RemoveAll(dir) -} - -// DeleteImportedProfile сносит и директорию профиля -// /var/lib/bj/profiles//, и связанную секцию из pki1.conf -// (между маркерами «# --- bj-server: профиль ---» и следующим -// «# --- bj-server: ...» или концом файла). -func DeleteImportedProfile(name string) error { - if !validProfileName(name) { - return errors.New("неверное имя профиля") - } - dir := filepath.Join(profilesDir, name) - if _, err := os.Stat(dir); err != nil { - return fmt.Errorf("профиль не найден: %w", err) - } - if err := os.RemoveAll(dir); err != nil { - return fmt.Errorf("удалить %s: %w", dir, err) - } - // Чистим секцию в pki1.conf — best effort, если файл недоступен на - // запись, профиль всё равно удалён, но в конфиге останется огрызок. - if err := removeFromPki1Conf(name); err != nil { - return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err) - } - return nil -} - -// removeFromPki1Conf удаляет блок профиля из pki1.conf. -// Блок начинается с «# --- bj-server: профиль ---» и кончается -// перед следующим таким маркером или до конца файла. Если блок не -// найден — успех (idempotent). -func removeFromPki1Conf(name string) error { - data, err := os.ReadFile(validataPki1Conf) - if err != nil { - return err - } - startMarker := "# --- bj-server: профиль " + name + " ---" - startIdx := strings.Index(string(data), startMarker) - if startIdx < 0 { - return nil - } - // Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF. - rest := string(data)[startIdx+len(startMarker):] - endRel := strings.Index(rest, "# --- bj-server: профиль ") - var newContent string - if endRel < 0 { - newContent = string(data)[:startIdx] - } else { - newContent = string(data)[:startIdx] + rest[endRel:] - } - // Убираем хвостовые пустые строки от секции. - newContent = strings.TrimRight(newContent, "\n") + "\n" - return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644) -} - -// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/. -func ListImportedProfiles() []string { - entries, err := os.ReadDir(profilesDir) - if err != nil { - return nil - } - var out []string - for _, e := range entries { - if e.IsDir() { - out = append(out, e.Name()) - } - } - return out -} - -// ListImportedContainers возвращает уже импортированные контейнеры. -func ListImportedContainers() []KeyContainer { - entries, err := os.ReadDir(containersDir) - if err != nil { - return nil - } - var out []KeyContainer - for _, e := range entries { - if !e.IsDir() { - continue - } - dir := filepath.Join(containersDir, e.Name()) - if c, ok := classifyContainer(dir); ok { - c.Imported = true - out = append(out, c) - } - } - return out -} - func isoID(absPath string) string { h := sha256.Sum256([]byte(absPath)) return hex.EncodeToString(h[:8]) diff --git a/internal/lkgateway/moex_logo.go b/internal/lkgateway/moex_logo.go new file mode 100644 index 0000000..d2fdc14 --- /dev/null +++ b/internal/lkgateway/moex_logo.go @@ -0,0 +1,16 @@ +package lkgateway + +// moex_logo.go — официальный логотип сервиса MOEX МОСТ (НРД) для встраивания в +// отчёты-доказательства регулятору. Источник: официальный набор НРД +// logo-moex-most.zip (вариант «Main color», RGB, красный #ff0508). +// +// Требования руководства НРД по размещению (rukovodstvo-o-razmeschenii-logotipa): +// • сохранять полное наименование сервиса «MOEX МОСТ»; +// • охранное поле (минимальный отступ до соседних элементов) ~0,5X вокруг; +// • на плашке — красной или белой (в приоритете красная), радиус 6px; +// • не искажать пропорции (viewBox 324.2×109 ≈ 3:1), не менять цвет. +// Вопросы по логотипу: moexmost-logo@nsd.ru. + +// moexMostLogoSVG — инлайн-SVG логотипа MOEX МОСТ (Main color, red). Высота +// задаётся через CSS/атрибут height у контейнера; пропорции сохраняются viewBox. +const moexMostLogoSVG = ` ` diff --git a/internal/lkgateway/news.go b/internal/lkgateway/news.go index 28d2996..9a31c40 100644 --- a/internal/lkgateway/news.go +++ b/internal/lkgateway/news.go @@ -47,8 +47,11 @@ func EnsureDocSources(rc *RuntimeConfig) { } } -// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive). -var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`) +// docFileRe — ищет ссылки на документы (.pdf/.docx/.doc/.zip/.xlsx/.xls) в HTML +// И в встроенном JSON. Сайт НРД — SPA (Next.js): ссылки лежат не в href="…", +// а внутри __NEXT_DATA__ как строки "/media/docs/…". Поэтому матчим значение в +// двойных ИЛИ экранированных кавычках, без требования атрибута href. +var docFileRe = regexp.MustCompile(`(?i)(?:href=)?\\?"((?:https?://[^"\\]+|/[^"\\]+)\.(?:pdf|docx?|zip|xlsx?))(?:\?[^"\\]*)?\\?"`) // noProxyClient — HTTP-клиент, который игнорирует переменные окружения // HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует @@ -84,23 +87,26 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string { s.DocSources[i].KnownPDFs = map[string]string{} } known := s.DocSources[i].KnownPDFs - fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs)) + fmt.Fprintf(&summary, " найдено %d ссылок на документы\n", len(pdfs)) newlyAdded := 0 for _, pdfURL := range pdfs { hash, changed := checkPDF(ctx, pdfURL, known) if !changed { continue } - known[pdfURL] = hash - newlyAdded++ localPath, err := downloadPDFToDOC(ctx, pdfURL) if err != nil { + // НЕ помечаем known — чтобы перекачать при следующей проверке. fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err) continue } + known[pdfURL] = hash // фиксируем только после успешной загрузки + newlyAdded++ fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath) - // Новость в ленту. - _ = rc.AddNews(NewsItem{ + // Новость добавляем в ЛОКАЛЬНЫЙ снапшот s (не rc.AddNews): в конце + // функции s целиком сохраняется через UpdateNews(s), и прямая запись + // в rc была бы затёрта этим снимком. + item := NewsItem{ ID: "doc-" + hash[:12], At: now, Kind: "doc-update", @@ -108,7 +114,18 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string { Body: "Источник: " + src.Name + "\nURL: " + pdfURL + "\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…", URL: pdfURL, - }) + } + // Дедуп по ID + новые сверху. + dup := false + for _, ex := range s.Items { + if ex.ID == item.ID { + dup = true + break + } + } + if !dup { + s.Items = append([]NewsItem{item}, s.Items...) + } } s.DocSources[i].LastChecked = now if newlyAdded > 0 { @@ -124,8 +141,9 @@ func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string { return summary.String() } -// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся -// на .pdf. Относительные URL разворачиваются в абсолютные. +// fetchPDFLinks качает страницу и извлекает ссылки на документы +// (.pdf/.docx/.doc/.zip/.xlsx) как из HTML-href, так и из встроенного JSON +// (Next.js SPA). Относительные URL разворачиваются в абсолютные. func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) { reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -152,20 +170,23 @@ func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) { if err != nil { return nil, err } - matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1) + matches := docFileRe.FindAllStringSubmatch(string(body), -1) seen := map[string]bool{} var out []string for _, m := range matches { - ref, err := url.Parse(m[1]) + raw := strings.ReplaceAll(m[1], "\\/", "/") // JSON-экранированные слэши + ref, err := url.Parse(raw) if err != nil { continue } abs := base.ResolveReference(ref).String() - // Игнорируем «системные» PDF (политика конфиденциальности и т.п.). + // Игнорируем «системные»/брендовые файлы и статику сборки SPA. low := strings.ToLower(abs) if strings.Contains(low, "personal_information") || strings.Contains(low, "personal-information") || - strings.Contains(low, "razmeschenie-logotipa") { + strings.Contains(low, "logotipa") || // руководство по логотипу (любое написание) + strings.Contains(low, "logo-moex") || + strings.Contains(low, "/_next/") { continue } if seen[abs] { @@ -219,19 +240,22 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) { return "", err } name := filepath.Base(u.Path) - if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".pdf", ".docx", ".doc", ".zip", ".xlsx", ".xls": + default: return "", errors.New("странное имя файла") } - docDir := "DOC" - if _, err := os.Stat(docDir); err != nil { - return "", fmt.Errorf("DOC/ не доступен: %w", err) + docDir := docsWritableDir() + if docDir == "" { + return "", errors.New("нет доступного для записи каталога документов") } dst := filepath.Join(docDir, name) - // Если файл уже есть — переименуем как backup. + // Если файл уже есть — переименуем как backup (расширение сохраняем). if _, err := os.Stat(dst); err == nil { old := filepath.Join(docDir, - strings.TrimSuffix(name, ".pdf")+ - "."+time.Now().Format("2006-01-02")+".pdf.bak") + strings.TrimSuffix(name, ext)+ + "."+time.Now().Format("2006-01-02")+ext+".bak") _ = os.Rename(dst, old) } @@ -263,6 +287,27 @@ func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) { return dst, nil } +// docsWritableDir выбирает каталог для скачанных документов НРД. Под systemd +// репозиторий /opt/bj/src/DOC монтируется read-only (ProtectSystem=strict), +// поэтому основной путь — /var/lib/bj/.bj/nsd-docs (в ReadWritePaths). В деве +// (репозиторий доступен на запись) используется DOC/. Возвращает "" если ни +// один путь не записываемый. +func docsWritableDir() string { + for _, d := range []string{"/var/lib/bj/.bj/nsd-docs", "DOC"} { + if err := os.MkdirAll(d, 0o755); err != nil { + continue + } + // Проверяем именно право записи (MkdirAll на существующий RO-каталог не ошибётся). + probe := filepath.Join(d, ".wtest") + if f, err := os.Create(probe); err == nil { + f.Close() + _ = os.Remove(probe) + return d + } + } + return "" +} + // StartDocWatcher запускает горутину, которая раз в сутки проверяет // DocSources и эмитирует новости. Стартует через 60 сек после Run(). func StartDocWatcher(rc *RuntimeConfig) func() { diff --git a/internal/lkgateway/npr.go b/internal/lkgateway/npr.go new file mode 100644 index 0000000..d091c76 --- /dev/null +++ b/internal/lkgateway/npr.go @@ -0,0 +1,568 @@ +package lkgateway + +// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли поверх базы +// экспорта QUIK (quik_export, схема npr). Перенесён «как есть» с 10.10.10.23 +// (FastAPI ui/app.py) 14.09.2026; читает базу логином только для чтения. +// Включается настройкой NPR.Enabled и лицензионным признаком "npr". + +import ( + "context" + "embed" + "encoding/csv" + "encoding/json" + "fmt" + "io/fs" + "log" + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed web/static/npr/* +var nprStaticFS embed.FS + +// NPRSettings — модуль НПР/УДС. +type NPRSettings struct { + Enabled bool `json:"enabled"` + DSN string `json:"dsn"` // postgres://quik_ro:...@host:5432/quik_export +} + +func (r *RuntimeConfig) UpdateNPR(s NPRSettings) error { + r.mu.Lock() + r.data.NPR = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// nprAvailable — модуль включён в настройках и разрешён лицензией. +func nprAvailable(rc *RuntimeConfig) bool { + if rc == nil { + return false + } + s := rc.Snapshot().NPR + return s.Enabled && strings.TrimSpace(s.DSN) != "" && moduleAllowed(rc, "npr") +} + +var ( + nprPoolMu sync.Mutex + nprPool *pgxpool.Pool + nprPoolDSN string +) + +func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) { + dsn := strings.TrimSpace(rc.Snapshot().NPR.DSN) + if dsn == "" { + return nil, fmt.Errorf("модуль НПР: не задано подключение к базе экспорта QUIK") + } + nprPoolMu.Lock() + defer nprPoolMu.Unlock() + if nprPool != nil && nprPoolDSN == dsn { + return nprPool, nil + } + if nprPool != nil { + nprPool.Close() + nprPool = nil + } + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 5 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + nprPool, nprPoolDSN = p, dsn + return p, nil +} + +var nprMSK = time.FixedZone("MSK", 3*3600) + +var nprSort = map[string]string{ + "uds_asc": "uds ASC NULLS LAST, client_code ASC", + "uds_desc": "uds DESC NULLS LAST, client_code ASC", + "client_asc": "client_code ASC", + "client_desc": "client_code DESC", + "ts_desc": "ts DESC", +} + +var nprTabs = map[string]string{ + // Маржинальный риск: клиенты с реально открытыми позициями. + "margin": "COALESCE(init_margin,0) > 0 OR COALESCE(min_margin,0) > 0", + // Дебиторская задолженность: позиций нет, но портфель в минусе. + "debt": "COALESCE(init_margin,0) = 0 AND COALESCE(min_margin,0) = 0 AND COALESCE(portfolio_value,0) < 0", +} + +type nprRow struct { + ClientCode, MarginType, UDSFlag string + UDS, RCV1, RCV2, MarginReq *float64 + PortfolioValue *float64 + InitMargin, MinMargin, CorrMargin *float64 + Status, RiskLevel *int + TS time.Time + Zone string +} + +func (r nprRow) Class() string { + switch { + case r.UDSFlag == "CALL" || (r.UDS != nil && *r.UDS < 1): + return "row-call" + case r.UDS != nil && *r.UDS < 1.5: + return "row-warn" + case r.UDSFlag == "INF": + return "row-inf" + } + return "" +} + +func (r nprRow) Badge() (cls, text string) { + switch { + case r.UDSFlag == "CALL": + return "badge-call", "МАРЖИН-КОЛЛ" + case r.UDSFlag == "INF": + return "badge-inf", "∞ избыточно" + case r.UDS != nil && *r.UDS < 1: + return "badge-call", "маржин-колл" + case r.UDS != nil && *r.UDS < 1.5: + return "badge-warn", "внимание" + case r.UDS != nil: + return "badge-ok", "норма" + } + return "", "—" +} +func (r nprRow) BadgeClass() string { c, _ := r.Badge(); return c } +func (r nprRow) BadgeText() string { _, t := r.Badge(); return t } + +func nprF(v *float64) string { + if v == nil || math.IsNaN(*v) { + return "—" + } + return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",") +} +func nprI(v *int) string { + if v == nil { + return "" + } + return strconv.Itoa(*v) +} + +func nprParseF(s string) *float64 { + s = strings.TrimSpace(strings.ReplaceAll(s, ",", ".")) + if s == "" { + return nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return &f +} + +func nprWhere(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) { + var cl []string + var params []any + cl = append(cl, "("+nprTabs[tab]+")") + if q != "" { + params = append(params, "%"+q+"%") + cl = append(cl, fmt.Sprintf("client_code ILIKE $%d", len(params))) + } + if len(mt) > 0 { + params = append(params, mt) + cl = append(cl, fmt.Sprintf("margin_type = ANY($%d)", len(params))) + } + if umn != nil { + params = append(params, *umn) + cl = append(cl, fmt.Sprintf("uds >= $%d", len(params))) + } + if umx != nil { + params = append(params, *umx) + cl = append(cl, fmt.Sprintf("uds <= $%d", len(params))) + } + if onlyCalls { + cl = append(cl, "uds IS NOT NULL AND uds < 1") + } + return strings.Join(cl, " AND "), params +} + +func scanNPR(rows pgx.Rows) ([]nprRow, int, error) { + var out []nprRow + total := 0 + for rows.Next() { + var r nprRow + var mt, fl *string + var zone *string + if err := rows.Scan(&r.ClientCode, &mt, &r.UDS, &fl, &r.RCV1, &r.RCV2, &r.MarginReq, &r.PortfolioValue, &r.InitMargin, &r.MinMargin, &r.CorrMargin, &r.Status, &r.RiskLevel, &r.TS, &zone, &total); err != nil { + return nil, 0, err + } + if mt != nil { + r.MarginType = *mt + } + if fl != nil { + r.UDSFlag = *fl + } + if zone != nil { + r.Zone = *zone + } + out = append(out, r) + } + return out, total, rows.Err() +} + +const nprCols = `client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, corrected_margin::float8, status, risk_level, ts, zone` + +func nprParsePeriod(hours int, from, to string) (time.Time, time.Time, bool) { + parse := func(s string) (time.Time, bool) { + for _, f := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} { + if t, err := time.ParseInLocation(f, s, nprMSK); err == nil { + return t, true + } + } + return time.Time{}, false + } + now := time.Now() + tf, okf := parse(from) + tt, okt := parse(to) + if okf || okt { + if !okf { + tf = now.Add(-2 * 365 * 24 * time.Hour) + } + if !okt { + tt = now + } + return tf, tt, true + } + if hours <= 0 { + hours = 24 + } + return now.Add(-time.Duration(hours) * time.Hour), now, false +} + +// registerNPR — маршруты модуля. +func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) { + sub, _ := fs.Sub(nprStaticFS, "web/static/npr") + mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub)))) + + guard := func(w http.ResponseWriter, r *http.Request) (*pgxpool.Pool, bool) { + if !nprAvailable(rc) { + http.Error(w, "модуль «НПР / УДС» не включён или не входит в лицензию", http.StatusForbidden) + return nil, false + } + p, err := nprDB(rc) + if err != nil { + http.Error(w, "модуль НПР: "+err.Error(), http.StatusServiceUnavailable) + return nil, false + } + return p, true + } + qp := func(r *http.Request) (q string, mt []string, umn, umx *float64, onlyCalls bool, sort, tab string, page int) { + v := r.URL.Query() + q = strings.TrimSpace(v.Get("q")) + for _, m := range v["mt"] { + if m = strings.TrimSpace(m); m != "" { + mt = append(mt, m) + } + } + umn, umx = nprParseF(v.Get("umn")), nprParseF(v.Get("umx")) + onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on" + sort = v.Get("sort") + if _, ok := nprSort[sort]; !ok { + sort = "uds_asc" + } + tab = v.Get("tab") + if _, ok := nprTabs[tab]; !ok { + tab = "margin" + } + page, _ = strconv.Atoi(v.Get("page")) + if page < 1 { + page = 1 + } + return + } + + mux.HandleFunc("/admin/npr", func(w http.ResponseWriter, r *http.Request) { + if _, ok := guard(w, r); !ok { + return + } + q, mt, _, _, onlyCalls, sort, tab, pg := qp(r) + data := struct { + page + Q, Sort, Tab string + MT []string + UMN, UMX string + OnlyCalls bool + Page int + }{nowPage("НПР / УДС", "npr"), q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg} + render(w, a.npr, data) + }) + + mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) { + p, ok := guard(w, r) + if !ok { + return + } + q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r) + limit := 30 + where, params := nprWhere(q, mt, umn, umx, onlyCalls, tab) + params = append(params, limit, (page-1)*limit) + sql := fmt.Sprintf(`WITH per_client AS ( + SELECT DISTINCT ON (client_code) %s FROM npr.v_current + ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC) + SELECT %s, count(*) OVER () AS total FROM per_client WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`, + nprCols, nprCols, where, nprSort[sort], len(params)-1, len(params)) + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + rows, err := p.Query(ctx, sql, params...) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + list, total, err := scanNPR(rows) + rows.Close() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + pages := (total + limit - 1) / limit + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = a.nprRows.ExecuteTemplate(w, "npr_rows", map[string]any{"Rows": list, "Total": total, "Page": page, "Pages": pages, "HasNext": total > page*limit}) + }) + + mux.HandleFunc("/admin/npr/api/summary", func(w http.ResponseWriter, r *http.Request) { + p, ok := guard(w, r) + if !ok { + return + } + tab := r.URL.Query().Get("tab") + if _, ok := nprTabs[tab]; !ok { + tab = "margin" + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + var total, below1, below0, calls, inf, warn, okc, tabM, tabD int64 + err := p.QueryRow(ctx, `WITH per_client AS (SELECT DISTINCT ON (client_code) client_code, uds, uds_flag, zone, init_margin, min_margin, portfolio_value FROM npr.v_current WHERE `+nprTabs[tab]+` ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC) + SELECT count(*), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 1), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 0), count(*) FILTER (WHERE zone='CALL'), count(*) FILTER (WHERE zone='INF'), count(*) FILTER (WHERE zone='WARN'), count(*) FILTER (WHERE zone='OK') FROM per_client`). + Scan(&total, &below1, &below0, &calls, &inf, &warn, &okc) + if err == nil { + err = p.QueryRow(ctx, `SELECT count(DISTINCT client_code) FILTER (WHERE `+nprTabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+nprTabs["debt"]+`) FROM npr.v_current`).Scan(&tabM, &tabD) + } + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"total": total, "below_one": below1, "below_zero": below0, "calls": calls, "inf": inf, "warn": warn, "ok": okc, "tab_margin": tabM, "tab_debt": tabD, "tab": tab}) + }) + + mux.HandleFunc("/admin/npr/api/freshness", func(w http.ResponseWriter, r *http.Request) { + p, ok := guard(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + var lastTick *time.Time + var ticks int64 + _ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks) + var lastTS *time.Time + var total, hour, day int64 + _ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day) + out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)} + if lastTick != nil { + out["last_tick"] = lastTick.Format(time.RFC3339) + } + if lastTS != nil { + out["last_event_ts"] = lastTS.Format(time.RFC3339) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + }) + + mux.HandleFunc("/admin/npr/c/", func(w http.ResponseWriter, r *http.Request) { + p, ok := guard(w, r) + if !ok { + return + } + rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/") + code := rest + sub := "" + if i := strings.Index(rest, "/"); i >= 0 { + code, sub = rest[:i], rest[i+1:] + } + if code == "" { + http.NotFound(w, r) + return + } + hours, _ := strconv.Atoi(r.URL.Query().Get("hours")) + tf, tt, custom := nprParsePeriod(hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to")) + if hours <= 0 { + hours = 24 + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + switch sub { + case "series": + rows, err := p.Query(ctx, "SELECT ts, rcv1::float8, rcv2::float8, uds::float8 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts", code, tf, tt) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + labels, r1, r2, u := []string{}, []*float64{}, []*float64{}, []*float64{} + for rows.Next() { + var ts time.Time + var a, b, c *float64 + if rows.Scan(&ts, &a, &b, &c) == nil { + labels = append(labels, ts.Format(time.RFC3339)) + r1, r2, u = append(r1, a), append(r2, b), append(u, c) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": labels, "rcv1": r1, "rcv2": r2, "uds": u}) + return + case "export.csv": + rows, err := p.Query(ctx, `SELECT h.ts, h.firm_id, h.client_code, COALESCE(h.margin_type,''), h.rcv1::float8, h.rcv2::float8, + CASE WHEN h.funds_level >= 9.99 THEN NULL ELSE h.funds_level::float8 END, CASE WHEN h.funds_level >= 9.99 THEN 'INF' WHEN h.funds_level <= -9.99 THEN 'CALL' ELSE '' END, + h.portfolio_value::float8, h.init_margin::float8, h.min_margin::float8, h.corrected_margin::float8, GREATEST(-h.rcv1, 0)::float8, h.status, h.risk_level + FROM npr.history h WHERE h.client_code=$1 AND h.ts BETWEEN $2 AND $3 ORDER BY h.ts`, code, tf, tt) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer rows.Close() + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="npr_%s_%s-%s.csv"`, code, tf.Format("20060102"), tt.Format("20060102"))) + w.Write([]byte{0xEF, 0xBB, 0xBF}) + cw := csv.NewWriter(w) + cw.Comma = ';' + cw.Write([]string{"Время (UTC)", "Время (МСК)", "Фирма", "Код клиента", "Тип клиента", "НПР1", "НПР2", "УДС", "Флаг УДС", "Стоимость портфеля", "Начальная маржа", "Минимальная маржа", "Скорректированная маржа", "Маржинальное требование", "Статус", "Уровень риска"}) + f := func(v *float64) string { + if v == nil { + return "" + } + return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 4, 64), ".", ",") + } + for rows.Next() { + var ts time.Time + var firm, cc, mt, flag string + var a, b, c, pv, im, mm, cm, mr *float64 + var st, rl *int + if rows.Scan(&ts, &firm, &cc, &mt, &a, &b, &c, &flag, &pv, &im, &mm, &cm, &mr, &st, &rl) != nil { + continue + } + cw.Write([]string{ts.UTC().Format("2006-01-02 15:04:05"), ts.In(nprMSK).Format("2006-01-02 15:04:05"), firm, cc, mt, f(a), f(b), f(c), flag, f(pv), f(im), f(mm), f(cm), f(mr), nprI(st), nprI(rl)}) + } + cw.Flush() + return + case "": + default: + http.NotFound(w, r) + return + } + rows, err := p.Query(ctx, `SELECT `+nprCols+`, 0 FROM npr.v_current WHERE client_code=$1 ORDER BY (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC LIMIT 1`, code) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + cur, _, err := scanNPR(rows) + rows.Close() + if err != nil || len(cur) == 0 { + http.Error(w, "Клиент "+code+" не найден в текущем состоянии QUIK", http.StatusNotFound) + return + } + hrows, err := p.Query(ctx, `SELECT client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, NULL::float8, NULL::int, NULL::int, ts, NULL::text, 0 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts DESC LIMIT 200`, code, tf, tt) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + hist, _, _ := scanNPR(hrows) + hrows.Close() + data := struct { + page + C nprRow + History []nprRow + Hours int + DtFrom, DtTo string + TF, TT time.Time + Custom bool + Periods []struct { + H int + T string + } + }{nowPage("Клиент "+code+" — НПР/УДС", "npr"), cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom, + []struct { + H int + T string + }{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}} + render(w, a.nprClient, data) + }) + log.Printf("npr: модуль зарегистрирован (включён: %v)", nprAvailable(rc)) +} + +// nprTest — проверка подключения для настроек. +func nprTest(rc *RuntimeConfig) (string, error) { + p, err := nprDB(rc) + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var clients, hist int64 + var tick *time.Time + if err := p.QueryRow(ctx, "SELECT count(DISTINCT client_code) FROM npr.v_current").Scan(&clients); err != nil { + return "", fmt.Errorf("npr.v_current: %v", err) + } + _ = p.QueryRow(ctx, "SELECT count(*) FROM npr.history").Scan(&hist) + _ = p.QueryRow(ctx, "SELECT last_tick FROM npr.heartbeat WHERE id=1").Scan(&tick) + age := "нет пульса" + if tick != nil { + age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second)) + } + return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s", clients, hist, age), nil +} + +func (h *setupHandlers) saveNPR(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := NPRSettings{Enabled: r.FormValue("enabled") != "", DSN: strings.TrimSpace(r.FormValue("dsn"))} + if err := h.rc.UpdateNPR(s); err != nil { + setupFlash(w, r, "Модуль НПР: ошибка сохранения: "+err.Error()) + return + } + if s.Enabled { + setupFlash(w, r, "Модуль НПР / УДС включён") + } else { + setupFlash(w, r, "Модуль НПР / УДС выключен") + } +} + +func (h *setupHandlers) testNPR(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if dsn := strings.TrimSpace(r.FormValue("dsn")); dsn != "" { + s := h.rc.Snapshot().NPR + if s.DSN != dsn { + s.DSN = dsn + _ = h.rc.UpdateNPR(s) + } + } + msg, err := nprTest(h.rc) + if err != nil { + setupFlash(w, r, "Модуль НПР: подключение НЕ удалось — "+err.Error()) + return + } + setupFlash(w, r, "Модуль НПР: подключение OK — "+msg) +} diff --git a/internal/lkgateway/nsd-certs/manifest.json b/internal/lkgateway/nsd-certs/manifest.json new file mode 100644 index 0000000..ac0be79 --- /dev/null +++ b/internal/lkgateway/nsd-certs/manifest.json @@ -0,0 +1,24 @@ +{ + "_comment": "Доверенные сертификаты НРД, которые bj поддерживает в справочнике активного профиля ИШ. Сертификат добавляется только если его УЦ уже есть в справочнике (контур совпадает). Старый удаляется не раньше remove_replaced_after.", + "certs": [ + { + "file": "nsd-prod-gost-shlyappo-20260821.cer", + "title": "Крипто-сервер НРД (ПРОМ, ГОСТ) — Шляппо А.А., от 21.08.2026", + "contour": "prod", + "serial": "40601E0097DD2440DD2813526A88308A", + "replaces_serial": "40601E00B54FB7C80229A05A684FE730", + "replaces_title": "Шляппо А.А. от 16.06.2025", + "remove_replaced_after": "2026-09-09T21:00:00+03:00", + "source": "https://www.nsd.ru/media/docs/edo/gost-2026-09-09.zip", + "note": "Плановая замена КЭП крипто-сервера НРД в ночь на 10.09.2026: добавить до 09.09, старый удалить 09.09 после 21:00" + }, + { + "file": "nsd-test-gost-petrov-20240924.cer", + "title": "Крипто-сервер НРД (ТЕСТ, ГОСТ) — Петров П.П., от 24.09.2024", + "contour": "test", + "serial": "405015B0BF2E6BDBE8485E7666F31C15", + "source": "https://www.nsd.ru/upload/docs/edo/NSD_2024.zip", + "note": "Тестовый контур (TEST3/GUEST)" + } + ] +} diff --git a/internal/lkgateway/nsd-certs/nsd-prod-gost-shlyappo-20260821.cer b/internal/lkgateway/nsd-certs/nsd-prod-gost-shlyappo-20260821.cer new file mode 100644 index 0000000000000000000000000000000000000000..18f0858f19fb4f51b43dbdc87e95bfa72cf552b6 GIT binary patch literal 2812 zcmd^BeQZ-z6z_ZQwjJf|gLNBB_VHk28M3YCy|(MhLfj|?#uyugj%5mA*$NakhZ6>< zguRMG(Ktn9WEs-QKoZmpMqw~F`9qX2gWRs-q< zc_K{E32ve@@-DYL6%tt|LX5T9B-))r+i8DLrDy30+9k(>kxlAFan!I0ve~df%%BJ9 zYxFH?WVWB42pM+;TO|5A?Flx~Q|KoJl9+)Y#?v-*-$eUp05|BTXC!)v9+9U&f=-Kv ztl?rKWl(H3v)!C$v&gdi2uukbUr+^(ixmNEHc;><&jOG#Ia0}mJO!75*_>B7InsQ8 z7ku3E2Z)uOArx!$SQLgKGKjJC0G=Pi4aIzb`sv;AO_ZjVfA8geYA%LWINb~ z;s$z@_6A$%hxqAd_I>mut`0B&Sf?xKLo|AWuY_>v3GR}Mp@^A^;#}H_UO%9Lpo)un z@Ec$YrOCOpXVfDd^@)Gk>O(&LW$@_-kiddF z*~oGC2oD;_(oLA9;|NCy5lAW|k94&HiE%Hgur6L$xWWQX*W4my3Cv=)F(e(PH$^Kj ziY_V}u*vcWf6x~$jv@&pE@@S5_ZR6Ka^9V{#JBwIYkv$l8wZe2hLO#Pj;MD)z1xKpEq|NxnpgG4)s!}I`9zPHD5?p0;Q)f@H73D>q>tBsA6DEB9&0# zw_}qKzal;`&HYoEslgvN-rYi);TAIpyP1Y?&UsE3fo-i;FYqCCN|uNS28htAd_!=C4erxsGQ$x4{-U7GiXU341bL zdgHgO8ZObuH*5|G-~D6SI&ZaSt89cxqm~LAH+bc!5J8E-s4XdSI4k5Ckj8=%j7iHp z4W6~$Cnfw}>)9ri*H+bd>%0y1jo#{Yh^R)RnflW;uB7a{WKEKv@vH7Henk-XTNIT_mHS|NseV@(HK-t;uTJJR_UCQQfEb$V~)!WWo)xlQUnXY z>38@G|Gh``#!&DZiTW+5S4A6g;UH}rCw#<(p@DB&&?hY(gG8=q`g=os+Q%`-MyNYY zvSP`$j9snSS5rzfMrJ)J&rK9NT|(bR*ww7e`oP&do{R=or0Z3y1sLtBwOqz zn<+ONONC)CAmMaBzO$o!;1wbaz_hSt|LA#s<;~A8Zok1_NsHH4pGfqkbU&n;efvsu zhUdA==ruE&vtb8AA(m`~M!yN0D7=~fw1!hfq-{Z%wtg!!kM!q=S(r_BF<5D`6H|j3 z#C(n^^SW8lgKQXGiwcFBm$%ny2Y>%{a9Z`OYpye8I;wtshg>>ue8-~f{&{cexsS4x i#F@7KX>AFG_WWa&+xo0&?QQL@J^5J|SDu@er2i8z(n-Gn literal 0 HcmV?d00001 diff --git a/internal/lkgateway/nsd-certs/nsd-test-gost-petrov-20240924.cer b/internal/lkgateway/nsd-certs/nsd-test-gost-petrov-20240924.cer new file mode 100644 index 0000000000000000000000000000000000000000..b29138fed4c4cb22da6b27fd4a15ac22f2585ab2 GIT binary patch literal 2681 zcmeHJYfKbZ6yBM+EVvAUE1;stgrY@-oinp9R%;ch6(TA@QDU1W1SH@iP!VDqnlgih zHX7{1YGMS2M@&tVW)-$Yh-;cQ*3#78X_~gNZ8RFImPVU4ZKXA#=PnOHY2xEw)A_MG z_ndp~-1FVP=uu@R#9PB~ zlO>!zN&(gDb^4qcUYDXM2{1FXeL)jA&MpFYy`bVxh6`Zsj40IwKGmh78DR5_D5pAZ z2s-C_^jt^qv59utOb@eMLkTxigB}!SgBiuTZeGJ=v2LMdSCI02KE z5VFu8E{PC>)QrWzUOIO^^jrER%Ch6mncRvbFOHKJ+&aZm% zg`NG+G@k0U>?`Wf!?%MTUIv>m>%?g!57rto1ArK*_lub^n+4d6*vXCu)4+x25ymdt zA}dEYN(j$cBk(;eEL`A`Aq%WiLY^ZVNi@;91T@9h#U5~c+b*UzoK8J|GeY^)cRAryjU|mVl4tDTwO5^>dUFjVM8%N0TP{pN(bma4S zwda@FTdW}+Nn~hFCGjM-{(8aV7rQ=O^JDO5>-?>^ziNy!ZjHkdQMUe%o`XA#y2rn2LV=C~o;0x#TxMQj{HQXsi5 zT7?P1sxgBXoMTFhJq0IEXTdyJLQNu3A|luz!fItY{yc|#AZ*fOM7s^zU|sCR_soQ= z&E+LIt--6GM1DEz%9-N>ZQ8j`?X&+-PZ~bz$W2e|&Fp^3Qgk8p7tjul3J%8+?KRLE zz&mOIzF|?>u3eRjWx2Gq^v~^3@tXo-_mbEm$0JNWmX;n$Ii=h4R z(A2_q^{<@7=6)|1#dO+6J7q<6rMo;nl{Gb8Qr!|#Ie=ZyPmd#S_e;n*C%ch|E9fCK z_F@BUG8-Ae8O^0jdk#v9%O`tfPdZ)}Mpgj{-9dBzU2A|=&&ZfWYT-pt4B1G30N{6S zhm}T_k=gLbkk_(J*hw{14R<=*NT<7xJCv~;V@Z_3B%m!+**aGC9)toBL;S`!2D~(M z(O~FmC8g50XbVQPFPL}{6f!L}L*@NVc_$BN))x6UHP?Q 0 { + o = n.Organization[0] + } + return n.CommonName, o +} + +// parseNSDCert принимает DER или PEM, возвращает сведения + DER. +func parseNSDCert(raw []byte) (nsdCertInfo, []byte, error) { + der := raw + if bytes.Contains(raw, []byte("-----BEGIN")) { + blk, _ := pem.Decode(raw) + if blk == nil { + return nsdCertInfo{}, nil, fmt.Errorf("PEM не разобран") + } + der = blk.Bytes + } + var c nsdASN1Cert + if _, err := asn1.Unmarshal(der, &c); err != nil { + return nsdCertInfo{}, nil, fmt.Errorf("ASN.1: %v", err) + } + info := nsdCertInfo{Serial: fmt.Sprintf("%X", c.TBS.Serial), NotBefore: c.TBS.Validity.NotBefore, NotAfter: c.TBS.Validity.NotAfter} + info.SubjectCN, info.SubjectO = nsdName(c.TBS.Subject) + info.IssuerCN, _ = nsdName(c.TBS.Issuer) + info.SelfSigned = bytes.Equal(c.TBS.Subject.FullBytes, c.TBS.Issuer.FullBytes) + return info, der, nil +} + +func normSerial(s string) string { + s = strings.ToUpper(strings.TrimSpace(s)) + s = strings.TrimPrefix(s, "0X") + return strings.TrimLeft(s, "0") +} + +// loadNSDCertBundle — вшитый комплект + ручные .cer оператора. +func loadNSDCertBundle() ([]nsdBundleCert, []string) { + var out []nsdBundleCert + var warns []string + mb, err := nsdCertsFS.ReadFile("nsd-certs/manifest.json") + if err != nil { + warns = append(warns, "манифест не найден: "+err.Error()) + } + var m nsdManifest + if err == nil { + if jerr := json.Unmarshal(mb, &m); jerr != nil { + warns = append(warns, "манифест не разобран: "+jerr.Error()) + } + } + for _, mc := range m.Certs { + raw, err := nsdCertsFS.ReadFile("nsd-certs/" + mc.File) + if err != nil { + warns = append(warns, mc.File+": "+err.Error()) + continue + } + info, der, err := parseNSDCert(raw) + if err != nil { + warns = append(warns, mc.File+": "+err.Error()) + continue + } + if mc.Serial != "" && normSerial(mc.Serial) != normSerial(info.Serial) { + warns = append(warns, fmt.Sprintf("%s: серийный в манифесте %s ≠ в файле %s — беру из файла", mc.File, mc.Serial, info.Serial)) + } + mc.Serial = info.Serial + if mc.Title == "" { + mc.Title = info.SubjectCN + } + out = append(out, nsdBundleCert{nsdManifestCert: mc, Info: info, DER: der, Origin: "bundle"}) + } + // ручные сертификаты оператора — без замен/удалений + if files, _ := filepath.Glob(filepath.Join(nsdCertsDropinDir, "*.cer")); len(files) > 0 { + sort.Strings(files) + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + continue + } + info, der, err := parseNSDCert(raw) + if err != nil { + warns = append(warns, filepath.Base(f)+": "+err.Error()) + continue + } + dup := false + for _, b := range out { + if normSerial(b.Serial) == normSerial(info.Serial) { + dup = true + break + } + } + if dup { + continue + } + out = append(out, nsdBundleCert{ + nsdManifestCert: nsdManifestCert{File: filepath.Base(f), Title: info.SubjectCN + " (ручной, " + filepath.Base(f) + ")", Contour: "manual", Serial: info.Serial}, + Info: info, DER: der, Origin: "dropin", + }) + } + } + return out, warns +} + +// ---- содержимое справочника (от root-воркера) ---- + +// NSDStoreCert — запись справочника профиля ИШ (JSON от certs-list). +type NSDStoreCert struct { + Kind string `json:"kind"` // x509 | crl + Store string `json:"store"` + Subject string `json:"subject"` + SubjectCN string `json:"subject_cn"` + IssuerCN string `json:"issuer_cn"` + Serial string `json:"serial"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + SelfSigned bool `json:"self_signed"` +} + +func nsdStoreFromResult(res map[string]any) []NSDStoreCert { + b, _ := json.Marshal(res["certs"]) + var st []NSDStoreCert + _ = json.Unmarshal(b, &st) + for i := range st { + st[i].NotBefore = nsdPrettyDate(st[i].NotBefore) + st[i].NotAfter = nsdPrettyDate(st[i].NotAfter) + } + return st +} + +func nsdPrettyDate(s string) string { + if t, err := time.Parse("Jan 2 15:04:05 2006 MST", strings.Join(strings.Fields(s), " ")); err == nil { + return t.Format("02.01.2006") + } + return s +} + +func workerCertsList(ctx context.Context) (string, []NSDStoreCert, error) { + res, err := runKeymediaOp(ctx, "certs-list", nil) + if err != nil { + return "", nil, err + } + prof, _ := res["profile"].(string) + return prof, nsdStoreFromResult(res), nil +} + +// ---- план ---- + +// NSDCertRow — строка таблицы «комплект ↔ справочник». +type NSDCertRow struct { + Title string `json:"title"` + Contour string `json:"contour"` + SubjectCN string `json:"subject_cn"` + IssuerCN string `json:"issuer_cn"` + Serial string `json:"serial"` + ValidFrom string `json:"valid_from"` + ValidTo string `json:"valid_to"` + Status string `json:"status"` + Kind string `json:"kind"` // ok | add | skip | expired | unknown + Action string `json:"action,omitempty"` + RemoveNote string `json:"remove_note,omitempty"` + RemoveSerial string `json:"remove_serial,omitempty"` + bundleIdx int +} + +func nsdCertsPlan(bundle []nsdBundleCert, store []NSDStoreCert, now time.Time) []NSDCertRow { + leaf := map[string]NSDStoreCert{} + ca := map[string]bool{} + for _, c := range store { + if c.Kind != "x509" { + continue + } + leaf[normSerial(c.Serial)] = c + if c.SelfSigned { + ca[strings.TrimSpace(c.SubjectCN)] = true + } + } + var rows []NSDCertRow + for i, b := range bundle { + row := NSDCertRow{ + Title: b.Title, Contour: b.Contour, SubjectCN: b.Info.SubjectCN, IssuerCN: b.Info.IssuerCN, Serial: b.Info.Serial, + ValidFrom: b.Info.NotBefore.Format("02.01.2006"), ValidTo: b.Info.NotAfter.Format("02.01.2006"), bundleIdx: i, + } + _, present := leaf[normSerial(b.Info.Serial)] + caPresent := ca[strings.TrimSpace(b.Info.IssuerCN)] + switch { + case now.After(b.Info.NotAfter): + row.Kind, row.Status = "expired", "истёк "+row.ValidTo+" — пропущен" + case present: + row.Kind, row.Status = "ok", "в справочнике ИШ" + case !caPresent: + row.Kind, row.Status = "skip", "не для этого контура — в справочнике нет УЦ «"+b.Info.IssuerCN+"»" + default: + row.Kind, row.Status, row.Action = "add", "будет добавлен в справочник", "add" + } + // замена старого сертификата + if b.ReplacesSerial != "" && row.Kind != "skip" && row.Kind != "expired" { + if old, ok := leaf[normSerial(b.ReplacesSerial)]; ok { + title := b.ReplacesTitle + if title == "" { + title = old.SubjectCN + } + after, perr := time.Parse(time.RFC3339, b.RemoveReplacedAfter) + switch { + case perr != nil || !now.Before(after): + row.RemoveSerial = old.Serial + row.RemoveNote = "старый сертификат «" + title + "» будет удалён" + if perr == nil { + row.RemoveNote += " (срок " + after.Local().Format("02.01.2006 15:04") + " наступил)" + } + default: + row.RemoveNote = "старый «" + title + "» остаётся до " + after.Local().Format("02.01.2006 15:04") + ", затем удалится автоматически" + } + } + } + rows = append(rows, row) + } + return rows +} + +func nsdPending(rows []NSDCertRow) int { + n := 0 + for _, r := range rows { + if r.Action == "add" { + n++ + } + if r.RemoveSerial != "" { + n++ + } + } + return n +} + +// ---- проверка и применение ---- + +func nsdSaveState(rc *RuntimeConfig, profile string, rows []NSDCertRow, store []NSDStoreCert, logText string, applied bool) { + s := rc.Snapshot().NSDCerts + s.LastRun = time.Now() + s.LastLog = logText + if profile != "" { + s.LastProfile = profile + } + if rows != nil { + s.LastRows = rows + } + if store != nil { + s.LastStore = store + } + if applied { + s.LastApplied = time.Now() + } + if err := rc.UpdateNSDCerts(s); err != nil { + log.Printf("nsdcerts: сохранение состояния: %v", err) + } +} + +// nsdCertsCheck — сверка без изменений. +func nsdCertsCheck(ctx context.Context, rc *RuntimeConfig) ([]NSDCertRow, error) { + profile, store, err := workerCertsList(ctx) + if err != nil { + nsdSaveState(rc, "", nil, nil, "сверка не выполнена: "+err.Error(), false) + return nil, err + } + bundle, warns := loadNSDCertBundle() + rows := nsdCertsPlan(bundle, store, time.Now()) + var sb strings.Builder + fmt.Fprintf(&sb, "профиль %s: в справочнике %d записей, в комплекте %d сертификатов, ожидает изменений: %d", profile, len(store), len(bundle), nsdPending(rows)) + for _, w := range warns { + sb.WriteString("\n⚠ " + w) + } + nsdSaveState(rc, profile, rows, store, sb.String(), false) + return rows, nil +} + +// nsdCertsApply — сверка и применение изменений (если они есть). +func nsdCertsApply(ctx context.Context, rc *RuntimeConfig, trigger string) (string, bool, error) { + profile, store, err := workerCertsList(ctx) + if err != nil { + nsdSaveState(rc, "", nil, nil, "сверка не выполнена: "+err.Error(), false) + return "", false, err + } + bundle, warns := loadNSDCertBundle() + now := time.Now() + rows := nsdCertsPlan(bundle, store, now) + var adds []nsdBundleCert + var removes []string + var what []string + for _, r := range rows { + if r.Action == "add" { + adds = append(adds, bundle[r.bundleIdx]) + what = append(what, "добавить «"+r.Title+"»") + } + if r.RemoveSerial != "" { + removes = append(removes, r.RemoveSerial) + what = append(what, r.RemoveNote) + } + } + if len(adds) == 0 && len(removes) == 0 { + msg := fmt.Sprintf("профиль %s: изменений не требуется (в справочнике %d записей)", profile, len(store)) + for _, w := range warns { + msg += "\n⚠ " + w + } + nsdSaveState(rc, profile, rows, store, msg, false) + return msg, false, nil + } + + dir := filepath.Join(nsdCertsApplyDir, now.Format("20060102-150405")) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", false, fmt.Errorf("staging: %w", err) + } + defer os.RemoveAll(dir) + for _, a := range adds { + if err := os.WriteFile(filepath.Join(dir, normSerial(a.Info.Serial)+".cer"), a.DER, 0o644); err != nil { + return "", false, fmt.Errorf("staging: %w", err) + } + } + res, err := runKeymediaOp(ctx, "certs-apply", map[string]string{"staging": dir, "extra": strings.Join(removes, ",")}) + if err != nil { + msg := "НЕ ПРИМЕНЕНО (" + trigger + "): " + err.Error() + "\nплан: " + strings.Join(what, "; ") + nsdSaveState(rc, profile, rows, store, msg, false) + _ = rc.AddNews(NewsItem{At: time.Now(), Kind: "system", Title: "Сертификаты НРД: не удалось обновить справочник ИШ", + Body: "Профиль " + profile + ". " + err.Error() + ". Справочник откачен из бэкапа, ИШ работает на прежнем наборе. План: " + strings.Join(what, "; ") + ". Проверьте носитель ключа и повторите в Настройки → Криптография."}) + return "", false, err + } + newStore := nsdStoreFromResult(res) + rows2 := nsdCertsPlan(bundle, newStore, now) + // контроль результата + var problems []string + for _, r := range rows2 { + if r.Action == "add" { + problems = append(problems, "«"+r.Title+"» так и не появился") + } + if r.RemoveSerial != "" { + problems = append(problems, "старый "+r.RemoveSerial+" всё ещё в справочнике") + } + } + igate, _ := res["igate"].(string) + backup, _ := res["backup"].(string) + msg := fmt.Sprintf("ПРИМЕНЕНО (%s) в %s: %s. ИШ перезапущен (%s), бэкап %s", trigger, now.Format("02.01.2006 15:04"), strings.Join(what, "; "), igate, backup) + if len(problems) > 0 { + msg += "\n⚠ " + strings.Join(problems, "; ") + } + for _, w := range warns { + msg += "\n⚠ " + w + } + nsdSaveState(rc, profile, rows2, newStore, msg, true) + _ = rc.AddNews(NewsItem{At: time.Now(), Kind: "system", Title: "Сертификаты НРД обновлены в справочнике ИШ", + Body: "Профиль " + profile + ": " + strings.Join(what, "; ") + ". Справочник пересобран, подпись проверена, ИШ перезапущен. Бэкап: " + backup + "."}) + log.Printf("nsdcerts: %s", strings.ReplaceAll(msg, "\n", " | ")) + return msg, true, nil +} + +// nsdCertsTick — один проход планировщика. +func nsdCertsTick(ctx context.Context, rc *RuntimeConfig) { + s := rc.Snapshot() + if s.NSD.IGWBaseURL == "" { + return // mock-режим, реального ИШ нет + } + if st, err := os.Stat(keymediaReqDir); err != nil || !st.IsDir() { + return // root-воркер не установлен + } + tctx, cancel := context.WithTimeout(ctx, 4*time.Minute) + defer cancel() + if s.NSDCerts.Manual { + if rows, err := nsdCertsCheck(tctx, rc); err == nil && nsdPending(rows) > 0 { + log.Printf("nsdcerts: ожидает изменений %d, автоприменение выключено", nsdPending(rows)) + } + return + } + if _, changed, err := nsdCertsApply(tctx, rc, "авто"); err != nil { + log.Printf("nsdcerts: авто: %v", err) + } else if !changed { + log.Printf("nsdcerts: авто: изменений не требуется") + } +} + +// StartNSDCertsSync — при старте (через 90 с, чтобы ИШ/воркер поднялись) и +// далее каждые 6 ч. Обновление бинаря bj → перезапуск → новый комплект +// применится сам. +func StartNSDCertsSync(rc *RuntimeConfig) func() { + ctx, cancel := context.WithCancel(context.Background()) + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(nsdCertsStartWait): + } + for { + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("nsdcerts: panic: %v", r) + } + }() + nsdCertsTick(ctx, rc) + }() + select { + case <-ctx.Done(): + return + case <-time.After(nsdCertsInterval): + } + } + }() + return cancel +} + +// ---- UI ---- + +// NSDCertsView — данные карточки «Сертификаты НРД в справочнике ИШ». +type NSDCertsView struct { + Profile string + Rows []NSDCertRow + Store []NSDStoreCert + LastRun time.Time + LastLog string + Manual bool + Pending int + Bundle int +} + +func nsdCertsView(s Settings) NSDCertsView { + v := NSDCertsView{Profile: s.NSDCerts.LastProfile, Rows: s.NSDCerts.LastRows, Store: s.NSDCerts.LastStore, + LastRun: s.NSDCerts.LastRun, LastLog: s.NSDCerts.LastLog, Manual: s.NSDCerts.Manual} + bundle, _ := loadNSDCertBundle() + v.Bundle = len(bundle) + if len(v.Rows) == 0 { + for _, b := range bundle { + v.Rows = append(v.Rows, NSDCertRow{Title: b.Title, Contour: b.Contour, SubjectCN: b.Info.SubjectCN, IssuerCN: b.Info.IssuerCN, Serial: b.Info.Serial, + ValidFrom: b.Info.NotBefore.Format("02.01.2006"), ValidTo: b.Info.NotAfter.Format("02.01.2006"), Kind: "unknown", Status: "ещё не сверялось"}) + } + } + v.Pending = nsdPending(v.Rows) + return v +} + +func (h *setupHandlers) saveNSDCerts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().NSDCerts + s.Manual = r.FormValue("auto") == "" + if err := h.rc.UpdateNSDCerts(s); err != nil { + setupFlash(w, r, "Сертификаты НРД: ошибка сохранения: "+err.Error()) + return + } + if s.Manual { + setupFlash(w, r, "Сертификаты НРД: автоприменение выключено — только по кнопке «Применить сейчас».") + } else { + setupFlash(w, r, "Сертификаты НРД: автоприменение включено (при старте и каждые 6 ч).") + } +} + +func (h *setupHandlers) checkNSDCerts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + rows, err := nsdCertsCheck(ctx, h.rc) + if err != nil { + setupFlash(w, r, "Сертификаты НРД: сверка не выполнена — "+err.Error()) + return + } + if n := nsdPending(rows); n > 0 { + setupFlash(w, r, fmt.Sprintf("Сертификаты НРД: сверено, ожидает изменений: %d — нажмите «Применить сейчас» или дождитесь автоприменения.", n)) + return + } + setupFlash(w, r, "Сертификаты НРД: сверено, справочник ИШ актуален.") +} + +func (h *setupHandlers) applyNSDCerts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute) + defer cancel() + msg, _, err := nsdCertsApply(ctx, h.rc, "вручную") + if err != nil { + setupFlash(w, r, "Сертификаты НРД: НЕ применено — "+err.Error()) + return + } + if len(msg) > 600 { + msg = msg[:600] + "…" + } + setupFlash(w, r, "Сертификаты НРД: "+strings.ReplaceAll(msg, "\n", " ")) +} diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go index 8e38431..2b8cb03 100644 --- a/internal/lkgateway/nsdpoller.go +++ b/internal/lkgateway/nsdpoller.go @@ -8,7 +8,9 @@ import ( "strings" "time" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" ) // pollerStateFile — постоянная память id применённых входящих пакетов ИШ. @@ -60,7 +62,7 @@ func saveProcessed(m map[int]bool) { // Дедупликация по id обработанных пакетов: ИШ возвращает их повторно, // пока мы не подтвердим, поэтому держим множество уже обработанных. func (s *Server) pollIncoming(ctx context.Context) { - const interval = 30 * time.Second + const interval = 10 * time.Second // синхронно с fetchInterval ИШ (10с) ticker := time.NewTicker(interval) defer ticker.Stop() @@ -79,6 +81,7 @@ func (s *Server) pollIncoming(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: + healthBeat("poll-incoming") s.fetchAndApply(ctx, processed) } } @@ -155,7 +158,8 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error { log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err) return nil } - return s.svc.ApplyDecision(ctx, decision) + s.applyDecisionEffects(ctx, decision, "поллер", "") + return nil case strings.Contains(doc, "M2MTransferResponse"): resp, err := igw.ParseResponse(unpacked.DocXML) if err != nil { @@ -177,9 +181,155 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error { // логируем, но помечаем обработанным, чтобы не зациклиться. log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err) } + // ERROR сервиса МОСТ — отказ: замыкаем на ЛК с человекочитаемой причиной. + if strings.EqualFold(string(resp.StatusCode), "ERROR") { + reason := "Отклонено сервисом MOEX МОСТ" + if c := strings.TrimSpace(codes); c != "" { + parts := make([]string, 0) + for _, code := range strings.Fields(c) { + parts = append(parts, m2mCodeLabel(code)) + } + reason = strings.Join(parts, "; ") + } + // Итог для заявки модуля ЛК (lk.transfer_requests): 'declined' — отказ после отправки в НРД. + if string(resp.GUID) != "" && string(resp.GUID) != zeroGUID { + if e := lkFinalizeByGUID(ctx, s.rc, string(resp.GUID), "declined", "rejected", reason); e != nil { + log.Printf("lk-gateway: поллер: итог заявки модуля ЛК GUID=%s: %v", resp.GUID, e) + } + } + OnLKOutcome(ctx, s.rc, string(resp.GUID), "rejected", reason) + quikOnServiceError(ctx, s.rc, string(resp.GUID), reason) + go depoOnServiceError(s.rc, string(resp.GUID), codes, reason) + } + return nil + case strings.Contains(doc, "M2MTransferRequest"): + // Входящий запрос на перевод — другая сторона (робот в сценарии 3333) + // выступает ПЕРЕДАЮЩЕЙ. Мы должны ответить M2MTransferDecision. Сохраняем + // во «входящие переводы»; решение оператор формирует в UI. + var req m2m.M2MTransferRequest + // Робот шлёт корень без namespace (префиксы только + // на детях) — подставляем default namespace request для корня. + if err := nsdxml.UnmarshalDefaultNS(unpacked.DocXML, &req, "http://nsd.ru/schemas/m2m/request"); err != nil { + log.Printf("lk-gateway: поллер входящий Request id=%d: разбор: %v", p.ID, err) + return nil + } + it, existed := s.incoming.AddFromRequest(&req, string(nsdxml.DecodeWindows1251(unpacked.DocXML))) + if existed { + log.Printf("lk-gateway: поллер входящий перевод GUID=%s уже сохранён (id=%s)", it.GUID, it.ID) + } else { + // Каждый новый GUID — ОТДЕЛЬНЫЙ перевод и требует решения оператора. + // Совпадение по «отпечатку» (тот же инвестор и бумаги) НЕ значит дубль: + // один и тот же человек может законно перевести 10 акций, затем ещё 10 + // (разные GUID = разные отправки). Поэтому авто-ответа нет — только + // информируем, если запрос похож на ранее отвеченный. + if dup, ok := s.incoming.FindAnsweredDuplicate(it.Fingerprint, it.ID); ok && it.Fingerprint != "" { + log.Printf("lk-gateway: поллер входящий GUID=%s похож по составу на ранее отвеченный (id=%s) — "+ + "это отдельный перевод, ждёт решения", it.GUID, dup.ID) + } else { + log.Printf("lk-gateway: поллер НОВЫЙ входящий перевод от %s GUID=%s id=%s — ждёт решения", + it.SenderCode, it.GUID, it.ID) + } + // Уведомляем депозитарий по почте (к вам переведены бумаги — учесть). + go notifyDepositoryIncoming(s.rc, it) + // DEPO (bj2depo): шапка + строки + BJ_RECEIVED — поручение в DEPO создаётся сразу. + go depoOnIncomingReceived(s.rc, it) + // Сверка с DEPO — только рекомендация для оператора (эмуляция ответа), + // в статус попадает бейдж «сверка: принять / отказ M2Mxx». Не отвечаем. + go func(item *IncomingTransfer) { + if r := runDepoCheck(item); r.Ran { + s.incoming.SetDepo(item.ID, r.Recommend, r.Summary) + log.Printf("lk-gateway: сверка DEPO по входящему GUID=%s: %s (не отправлено)", item.GUID, r.Summary) + } else { + log.Printf("lk-gateway: сверка DEPO по входящему GUID=%s не выполнена: %s", item.GUID, r.Err) + } + }(it) + } + return nil + case strings.Contains(doc, "M2MTransferHandbook") && !strings.Contains(doc, "M2MTransferHandbookRequest"): + // Справочник участников M2M (ответ НРД на #M2MHR). Разбираем и сохраняем + // — это карта ИНН → MC-код участника и перечень мест расчётов. + var hb m2m.M2MTransferHandbook + if err := nsdxml.Unmarshal(unpacked.DocXML, &hb); err != nil { + log.Printf("lk-gateway: поллер справочник M2M id=%d: разбор: %v", p.ID, err) + return nil + } + saveHandbook(&hb) + go func() { + if err := depoWriteHandbook(s.rc); err != nil { + log.Printf("depo-events: справочник после получения: %v", err) + } + }() + log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d", + p.ID, len(hb.Participants.Participants), len(hb.SettlementPlaces.Places)) return nil default: log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type) return nil } } + +// rejectCodesLabel собирает человекочитаемые причины отказа по кодам M2M. +func rejectCodesLabel(codes []string) string { + if len(codes) == 0 { + return "без кода" + } + seen := map[string]bool{} + var parts []string + for _, c := range codes { + if c == "" || seen[c] { + continue + } + seen[c] = true + parts = append(parts, m2mCodeLabel(c)) + } + return strings.Join(parts, "; ") +} + +// handbookFile — последний полученный справочник участников M2M (для UI/поиска +// ИНН→MC-код контрагента). +const handbookFile = "/var/lib/bj/.bj/m2m-handbook.json" + +// HandbookEntry — плоская запись справочника для сохранения/показа. +type HandbookEntry struct { + INN string `json:"inn"` + Code string `json:"code"` // MC-код участника (депозитарий/брокер) + Kind string `json:"kind"` // "depo" | "broker" | "place" + Name string `json:"name"` // краткое (рус.) + FullName string `json:"full_name,omitempty"` + DisplayName string `json:"display_name,omitempty"` + EngName string `json:"eng_name,omitempty"` +} + +// saveHandbook раскладывает справочник в плоский список и пишет в файл. +func saveHandbook(hb *m2m.M2MTransferHandbook) { + out := struct { + At string `json:"at"` + Participants []HandbookEntry `json:"participants"` + Places []HandbookEntry `json:"settlement_places"` + }{At: time.Now().Format(time.RFC3339)} + for _, p := range hb.Participants.Participants { + e := HandbookEntry{INN: string(p.INN), Name: p.Names.Rus.ShortName, FullName: p.Names.Rus.FullName, DisplayName: p.Names.Rus.DisplayName} + if p.Names.Eng != nil { + e.EngName = p.Names.Eng.DisplayName + if e.EngName == "" { + e.EngName = p.Names.Eng.FullName + } + } + if e.Name == "" { + e.Name = e.DisplayName + } + if p.DepositoryPlace != nil { + e.Code, e.Kind = string(p.DepositoryPlace.ParticipantCode), "depo" + } else if p.BrokerPlace != nil { + e.Code, e.Kind = string(p.BrokerPlace.ParticipantCode), "broker" + } + out.Participants = append(out.Participants, e) + log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name) + } + for _, pl := range hb.SettlementPlaces.Places { + out.Places = append(out.Places, HandbookEntry{INN: string(pl.INN), Name: pl.ShortName, DisplayName: pl.DisplayName, Kind: "place"}) + } + if raw, err := json.Marshal(out); err == nil { + _ = os.WriteFile(handbookFile, raw, 0o600) + } +} diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go new file mode 100644 index 0000000..ece6799 --- /dev/null +++ b/internal/lkgateway/orderwatch.go @@ -0,0 +1,232 @@ +package lkgateway + +// Контроль сроков депо-поручений 16 / 16/1 (регламент депозитария, 09.09.2026): +// - отчёт MS010 ожидается в течение 5 минут после подачи; нет — оповещение +// «не исполнено в срок»; +// - далее исполнение ожидается до 30 дней; по истечении — поручение считается +// отменённым (оповещение, по исходящему снимается блокировка в QUIK). +// Состояние — /var/lib/bj/.bj/order-watch.json (чтобы оповещать один раз). + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + orderWatchFile = "/var/lib/bj/.bj/order-watch.json" + orderMS010Within = 5 * time.Minute + orderExecWithin = 30 * 24 * time.Hour +) + +type OrderWatch struct { + GUID string `json:"guid"` + Direction string `json:"direction"` // out (16) | in (16/1) + OrderAt time.Time `json:"order_at"` + MS010At time.Time `json:"ms010_at,omitempty"` + Rejected bool `json:"rejected,omitempty"` + LateAt time.Time `json:"late_at,omitempty"` // когда зафиксировали просрочку 5 мин + ExpiredAt time.Time `json:"expired_at,omitempty"` // когда истекли 30 дней +} + +// Состояние по-русски для карточек. +func (w *OrderWatch) StatusRu() string { + if w == nil { + return "" + } + switch { + case !w.MS010At.IsZero() && w.Rejected: + return "НРД отверг поручение (MS010 " + w.MS010At.Local().Format("02.01 15:04") + ")" + case !w.MS010At.IsZero(): + late := "" + if w.MS010At.Sub(w.OrderAt) > orderMS010Within { + late = fmt.Sprintf(", с опозданием %s", w.MS010At.Sub(w.OrderAt).Round(time.Minute)) + } + return "исполнено: MS010 получен " + w.MS010At.Local().Format("02.01.2006 15:04") + late + case !w.ExpiredAt.IsZero(): + return "СРОК 30 ДНЕЙ ИСТЁК " + w.ExpiredAt.Local().Format("02.01.2006") + " — поручение считается отменённым" + case !w.LateAt.IsZero(): + return "НЕ ИСПОЛНЕНО В СРОК 5 МИН (подано " + w.OrderAt.Local().Format("02.01 15:04") + "); ожидаем исполнения до " + w.OrderAt.Add(orderExecWithin).Local().Format("02.01.2006") + default: + return "ожидаем MS010 до " + w.OrderAt.Add(orderMS010Within).Local().Format("15:04") + } +} + +func (w *OrderWatch) Kind() string { + switch { + case w == nil: + return "" + case !w.MS010At.IsZero() && w.Rejected: + return "bad" + case !w.MS010At.IsZero(): + return "ok" + case !w.ExpiredAt.IsZero(): + return "bad" + case !w.LateAt.IsZero(): + return "warn" + } + return "wait" +} + +type orderWatchStore struct { + mu sync.Mutex + items map[string]*OrderWatch +} + +var globalOrderWatch = loadOrderWatch() + +func loadOrderWatch() *orderWatchStore { + s := &orderWatchStore{items: map[string]*OrderWatch{}} + if b, err := os.ReadFile(orderWatchFile); err == nil { + var list []*OrderWatch + if json.Unmarshal(b, &list) == nil { + for _, w := range list { + s.items[w.GUID] = w + } + } + } + return s +} + +func (s *orderWatchStore) saveLocked() { + list := make([]*OrderWatch, 0, len(s.items)) + for _, w := range s.items { + list = append(list, w) + } + b, _ := json.MarshalIndent(list, "", " ") + _ = os.MkdirAll(filepath.Dir(orderWatchFile), 0o755) + tmp := orderWatchFile + ".tmp" + if os.WriteFile(tmp, b, 0o640) == nil { + _ = os.Rename(tmp, orderWatchFile) + } +} + +// Get — состояние контроля по GUID (nil, если поручение не подавалось). +func (s *orderWatchStore) Get(guid string) *OrderWatch { + s.mu.Lock() + defer s.mu.Unlock() + if w, ok := s.items[guid]; ok { + cp := *w + return &cp + } + return nil +} + +func (s *orderWatchStore) update(guid string, fn func(w *OrderWatch)) *OrderWatch { + s.mu.Lock() + defer s.mu.Unlock() + w, ok := s.items[guid] + if !ok { + w = &OrderWatch{GUID: guid} + s.items[guid] = w + } + fn(w) + s.saveLocked() + cp := *w + return &cp +} + +var orderWatchStarted = time.Now() + +// orderWatchCheck — один объект: подано когда, ответы НРД → фиксируем MS010/просрочку/истечение. +func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction string, orderAt time.Time, resps []orderResponse, label string) { + if guid == "" || orderAt.IsZero() { + return + } + w := globalOrderWatch.Get(guid) + if w == nil { + w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.Direction, w.OrderAt = direction, orderAt }) + } + if !w.MS010At.IsZero() || !w.ExpiredAt.IsZero() { + return // финальное состояние уже зафиксировано + } + if done, rejected, text := orderRespVerdict(resps); done { + at := time.Now() + for _, r := range resps { + if r.Done { + if t, err := time.ParseInLocation("02.01.2006 15:04:05", r.Time, time.Local); err == nil { + at = t + } + } + } + globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At, w.Rejected = at, rejected }) + if rejected { + go depoOnOrderResult(rc, guid, "rejected", text) + } else { + go depoOnOrderResult(rc, guid, "accepted", "") + } + return + } + now := time.Now() + fresh := orderAt.After(orderWatchStarted) // по старым поручениям при первом запуске не шумим + if w.LateAt.IsZero() && now.Sub(orderAt) > orderMS010Within { + globalOrderWatch.update(guid, func(w *OrderWatch) { w.LateAt = now }) + go depoOnOrderResult(rc, guid, "late", "") + if fresh { + raiseSystemAlert(rc, "НРД: депо-поручение не исполнено в срок 5 минут", + fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 не получен. Ожидаем исполнения до %s, после этого поручение считается отменённым.", + label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"), orderAt.Add(orderExecWithin).Local().Format("02.01.2006"))) + } + } + if w.ExpiredAt.IsZero() && now.Sub(orderAt) > orderExecWithin { + globalOrderWatch.update(guid, func(w *OrderWatch) { w.ExpiredAt = now }) + go depoOnOrderResult(rc, guid, "expired", "") + raiseSystemAlert(rc, "НРД: истёк срок 30 дней по депо-поручению — считается отменённым", + fmt.Sprintf("%s (перевод %s): подано %s, отчёт MS010 так и не получен. Поручение считается отменённым; проверьте перевод в НРД и позицию клиента.", + label, shortGUID(guid), orderAt.Local().Format("02.01.2006 15:04"))) + if direction == "out" { + for _, l := range globalQuikLinks.ForGUID(guid) { + if l.State == "registered" { + _, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Истёк срок исполнения поручения в НРД (30 дней) — перевод отменён") + } + } + } + } +} + +func orderWatchTick(ctx context.Context, rc *RuntimeConfig) { + if globalOutgoingStore != nil { + for _, od := range scanForm16Drafts(globalOutgoingStore) { + if !od.Submitted { + continue + } + resps, _ := outgoingResponses(od.ContrDIDs) + orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, resps, "Поручение 16 (списание)") + } + } + if globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + if !it.OrderSubmitted { + continue + } + orderWatchCheck(ctx, rc, it.GUID, "in", it.OrderAt, findOrderResponses(it.OrderContrDID), "Поручение 16/1 (зачисление)") + } + } +} + +// orderWatchLoop — фоновый контроль сроков (раз в минуту). +func (s *Server) orderWatchLoop(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("order-watch: panic: %v", r) + } + }() + orderWatchTick(ctx, s.rc) + }() + healthBeat("order-watch") + } +} diff --git a/internal/lkgateway/outgoing_export.go b/internal/lkgateway/outgoing_export.go new file mode 100644 index 0000000..a8ad517 --- /dev/null +++ b/internal/lkgateway/outgoing_export.go @@ -0,0 +1,128 @@ +package lkgateway + +// outgoing_export.go — выгрузка для регулятора по исходящему переводу (списание): +// отчёт-доказательство с хронологией депозитарного исполнения (черновик 16 → +// поручение 16 подано → отчёт MS010 о списании) + дословный XML отправленного 16. + +import ( + "archive/zip" + "fmt" + "html" + "net/http" + "strings" + "time" +) + +func exportOutgoing(w http.ResponseWriter, r *http.Request, deps outgoingDeps, guid string) { + deal, ok := scanForm16Deal(deps.store, guid) + if !ok { + http.Error(w, "исходящая сделка не найдена", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="ishodyaschiy-%s.zip"`, shortID(guid))) + zw := zip.NewWriter(w) + defer zw.Close() + if f, e := zw.Create("otchet.html"); e == nil { + _, _ = f.Write(outgoingReportHTML(deal)) + } + if deal.OrderXML != "" { + if f, e := zw.Create("poruchenie-16.xml"); e == nil { + _, _ = f.Write(faithfulXMLBytes(deal.OrderXML)) + } + } + // Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX. + for _, rf := range collectOutgoingResponseRaw(deal.ContrDIDs) { + if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil { + _, _ = f.Write(rf.Data) + } + } +} + +func outgoingReportHTML(d *OutgoingDeal) []byte { + esc := html.EscapeString + msk := func(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + " МСК" + } + var b strings.Builder + b.WriteString(``) + b.WriteString(`Отчёт об исходящем переводе M2M (списание)`) + b.WriteString(`
    ` + moexMostLogoSVG + + `Сервис быстрых переводов ценных бумаг
    MOEX МОСТ · НКО АО НРД
    ` + orgLogoIMG() + `
    `) + b.WriteString(`

    Отчёт об исходящем переводе ценных бумаг (списание, M2M / MOEX МОСТ)

    `) + b.WriteString(`
    Доказательство для контролирующих органов. GUID сделки: ` + esc(d.GUID) + + ` · Сформировано: ` + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + ` МСК
    `) + + row := func(k, v string) { b.WriteString(`` + k + `` + v + ``) } + b.WriteString(`

    Реквизиты перевода

    `) + row("GUID сделки", ``+esc(d.GUID)+``) + if d.DealRef != "" { + row("Референс сделки (deal_reference)", esc(d.DealRef)) + } + to := esc(d.CounterpartyCode) + if n := handbookNameByCode(d.CounterpartyCode); n != "" { + to = `` + esc(n) + ` · ` + to + } + row("Принимающий депозитарий (контрагент)", to) + b.WriteString(`
    `) + + var ms010At string + for _, rp := range d.Responses { + if rp.Done { + ms010At = rp.Time + } + } + b.WriteString(`

    Депозитарное исполнение (поручение 16 — списание) — хронология

    `) + row("Черновик 16 получен от НРД (MF010)", msk(d.DraftAt)) + if d.Submitted { + row("Поручение 16 подано в НРД", msk(d.OrderAt)) + row("Тип ЭД / пакет(ы) ИШ", "ORDXM · "+esc(d.Pkgs)) + if len(d.ContrDIDs) > 0 { + row("Номера поручений (contr_d_id)", esc(strings.Join(d.ContrDIDs, ", "))) + } + } else { + row("Поручение 16 подано в НРД", `— (ещё не подано)`) + } + if ms010At != "" { + row("Отчёт MS010 получен (списание проведено)", esc(ms010At)+` МСК — бумага списана и передана контрагенту`) + } else { + row("Отчёт MS010 получен", `— (ещё не получен)`) + } + b.WriteString(`
    `) + + b.WriteString(`

    Ценные бумаги

    `) + for _, s := range d.Secs { + code := esc(s.SecurityCode) + if s.ISIN != "" && s.ISIN != s.SecurityCode { + code += ` / ` + esc(s.ISIN) + } + b.WriteString(``) + } + b.WriteString(`
    Код НРД / ISINКол-во№ поручения
    ` + code + `` + esc(s.Quantity) + `` + esc(s.ContrDID) + `
    `) + + if len(d.Responses) > 0 { + b.WriteString(`

    Ответы НРД по поручению

    `) + for _, rp := range d.Responses { + b.WriteString(``) + } + b.WriteString(`
    Время полученияТип ответаПакет
    ` + esc(rp.Time) + ` МСК` + esc(rp.Kind) + `` + esc(rp.Pkg) + `
    `) + } + if d.OrderXML != "" { + b.WriteString(`

    Отправленное поручение 16 (дословно)

    ` + esc(d.OrderXML) + `
    `) + } + b.WriteString(`

    Отчёт сформирован автоматически системой Bridge-and-Join-s (bj). Логотип MOEX МОСТ — собственность НКО АО НРД.

    `) + b.WriteString(``) + return []byte(b.String()) +} diff --git a/internal/lkgateway/outgoing_order.go b/internal/lkgateway/outgoing_order.go new file mode 100644 index 0000000..632c706 --- /dev/null +++ b/internal/lkgateway/outgoing_order.go @@ -0,0 +1,416 @@ +package lkgateway + +// outgoing_order.go — депозитарное исполнение ИСХОДЯЩЕГО перевода M2M. +// +// Когда МЫ выступаем СПИСЫВАЮЩИМ депозитарием (сценарий 2), после согласия +// контрагента НРД кладёт нам в INBOX черновик простой формы 16 (списание) — +// тот же тип файла ..._MF010.xml, но с 16 (у зачисления +// было 16/1). Мы обязаны подать это поручение 16 в НРД (тип ЭД ORDXM, как и 16/1). +// НРД сматчит наш 16 со встречным 16/1 контрагента и, когда операция проведётся, +// пришлёт нам отчёт MS010 по нашему поручению — подтверждение, что бумага ушла. +// +// Пока подача — вручную кнопкой (как во входящих). Логика подачи вынесена в +// submitOutgoingFor, чтобы позже её мог дёргать авто-поллер (см. TODO auto). +// Серверная механика (упаковка ORDXM, отправка через ИШ, детект MS010) — +// переиспользуется из входящего потока (igw.PackDepoOrder, findOrderResponses). + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" +) + +const outgoingStateFile = "/var/lib/bj/.bj/outgoing-orders.json" + +// globalOutgoingStore — стор исходящего депо-исполнения, доступный карточке заявки +// (renderClaim показывает блок формы 16 прямо внутри исходящего перевода). +var globalOutgoingStore *OutgoingStore + +// outgoingDeps — зависимости хендлеров исходящего депо-исполнения. +type outgoingDeps struct { + store *OutgoingStore + igwClient *igw.Client + igwChan string +} + +// OutgoingSubmit — сохраняемое состояние подачи поручения 16 по одной сделке +// (ключ — GUID сделки = based_on черновика). Хранится локально (JSON), не в Fancy. +type OutgoingSubmit struct { + GUID string `json:"guid"` + ContrDIDs []string `json:"contr_dids"` // номера поданных поручений (для детекта MS010) + Pkgs string `json:"pkgs"` // id пакетов ИШ + OrderAt time.Time `json:"order_at"` + OrderXML string `json:"order_xml"` // что именно отправили (все формы 16, дословно) +} + +// OutgoingStore — потокобезопасное хранилище состояния подачи (переживает рестарт). +type OutgoingStore struct { + mu sync.Mutex + items map[string]*OutgoingSubmit +} + +func NewOutgoingStore() *OutgoingStore { + s := &OutgoingStore{items: make(map[string]*OutgoingSubmit)} + if b, err := os.ReadFile(outgoingStateFile); err == nil { + var list []*OutgoingSubmit + if json.Unmarshal(b, &list) == nil { + for _, it := range list { + s.items[it.GUID] = it + } + } + } + return s +} + +func (s *OutgoingStore) save() { + list := make([]*OutgoingSubmit, 0, len(s.items)) + for _, it := range s.items { + list = append(list, it) + } + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return + } + tmp := outgoingStateFile + ".tmp" + if os.WriteFile(tmp, b, 0o640) == nil { + _ = os.Rename(tmp, outgoingStateFile) + } +} + +func (s *OutgoingStore) Get(guid string) (*OutgoingSubmit, bool) { + s.mu.Lock() + defer s.mu.Unlock() + it, ok := s.items[guid] + if !ok { + return nil, false + } + cp := *it + cp.ContrDIDs = append([]string(nil), it.ContrDIDs...) + return &cp, true +} + +// SetSubmitted фиксирует факт подачи поручения(й) 16 в НРД по сделке. +func (s *OutgoingStore) SetSubmitted(guid string, contrDIDs []string, pkgs, xml string) { + s.mu.Lock() + defer s.mu.Unlock() + s.items[guid] = &OutgoingSubmit{ + GUID: guid, ContrDIDs: contrDIDs, Pkgs: pkgs, + OrderAt: time.Now().UTC(), OrderXML: xml, + } + s.save() +} + +// --- разбор черновика формы 16 из INBOX --- + +func of16(body, tag string) string { + m := regexp.MustCompile(`<` + tag + `>\s*([^<]*?)\s*`).FindStringSubmatch(body) + if m == nil { + return "" + } + return strings.TrimSpace(m[1]) +} + +// OutgoingSec — бумага в исходящем поручении 16. +type OutgoingSec struct { + SecurityCode string + ISIN string + Quantity string + ContrDID string // номер поручения по этой бумаге + File string // путь к черновику MF010 +} + +// OutgoingDeal — сделка исходящего перевода (группа черновиков 16 по одному GUID), +// объединённая с состоянием подачи. +type OutgoingDeal struct { + GUID string + DealRef string + CounterpartyCode string // corr_code — принимающий контрагент + DraftAt time.Time + Secs []OutgoingSec + // подача (из стора): + Submitted bool + Pkgs string + OrderAt time.Time + OrderXML string + ContrDIDs []string + // ответ НРД: + Responses []orderResponse + Done bool // получен MS010 по всем поручениям +} + +// scanForm16Drafts живьём читает INBOX и собирает исходящие сделки: все черновики +// с 16, сгруппированные по based_on (GUID). Объединяет с +// состоянием подачи из стора. store может быть nil (тогда без состояния подачи). +func scanForm16Drafts(store *OutgoingStore) []*OutgoingDeal { + byGUID := map[string]*OutgoingDeal{} + _ = filepath.Walk(igwInboxDir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !strings.HasSuffix(strings.ToUpper(p), "_MF010.XML") { + return nil + } + b, e := os.ReadFile(p) + if e != nil { + return nil + } + body := string(decodeCP1251(b)) + if of16(body, "order_t_id") != "16" { // только списание (16), не 16/1 + return nil + } + guid := of16(body, "based_on") + if guid == "" { + return nil + } + d := byGUID[guid] + if d == nil { + d = &OutgoingDeal{ + GUID: guid, + DealRef: of16(body, "deal_reference"), + CounterpartyCode: of16(body, "corr_code"), + } + byGUID[guid] = d + } + if d.DraftAt.IsZero() || info.ModTime().Before(d.DraftAt) { + d.DraftAt = info.ModTime() + } + sec := OutgoingSec{ + SecurityCode: of16(body, "security_c"), + ISIN: of16(body, "security_isin"), + Quantity: of16(body, "security_q"), + ContrDID: of16(body, "contr_d_id"), + File: p, + } + d.Secs = append(d.Secs, sec) + return nil + }) + out := make([]*OutgoingDeal, 0, len(byGUID)) + for _, d := range byGUID { + if store != nil { + if sub, ok := store.Get(d.GUID); ok { + d.Submitted = true + d.Pkgs = sub.Pkgs + d.OrderAt = sub.OrderAt + d.OrderXML = sub.OrderXML + d.ContrDIDs = sub.ContrDIDs + d.Responses, d.Done = outgoingResponses(sub.ContrDIDs) + } + } + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].DraftAt.After(out[j].DraftAt) }) + return out +} + +func scanForm16Deal(store *OutgoingStore, guid string) (*OutgoingDeal, bool) { + for _, d := range scanForm16Drafts(store) { + if d.GUID == guid { + return d, true + } + } + return nil, false +} + +// outgoingResponses агрегирует ответы НРД (MS010/REORD/…) по всем поручениям сделки. +// done=true, когда MS010 получен хотя бы по одному поручению. +func outgoingResponses(contrDIDs []string) ([]orderResponse, bool) { + seen := map[string]bool{} + var out []orderResponse + var done bool + for _, cd := range contrDIDs { + for _, rp := range findOrderResponses(cd) { + if seen[rp.Pkg] { + continue + } + seen[rp.Pkg] = true + out = append(out, rp) + if rp.Done { + done = true + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Time < out[j].Time }) + return out, done +} + +// submitOutgoingFor подаёт все черновики формы 16 по сделке (GUID) в НРД типом +// ORDXM. Вынесено отдельной функцией, чтобы позже дёргать из авто-поллера. +// Возвращает id пакетов ИШ, номера поручений (contr_d_id) и отправленный XML. +func submitOutgoingFor(ctx context.Context, deps outgoingDeps, guid string) (pkgs, contrDIDs []string, orderXML string, err error) { + if deps.igwClient == nil || deps.igwChan == "" { + return nil, nil, "", fmt.Errorf("ИШ не настроен") + } + deal, ok := scanForm16Deal(deps.store, guid) + if !ok || len(deal.Secs) == 0 { + return nil, nil, "", fmt.Errorf("черновик формы 16 по этой сделке в INBOX не найден") + } + var xmls []string + var firstErr error + for _, sec := range deal.Secs { + raw, e := os.ReadFile(sec.File) // windows-1251, шлём как есть + if e != nil { + firstErr = e + continue + } + zipBody, e := igw.PackDepoOrder(raw, filepath.Base(sec.File)) + if e != nil { + firstErr = e + continue + } + pkgID, e := deps.igwClient.SendPackage(ctx, deps.igwChan, "K", zipBody) + if e != nil { + firstErr = e + continue + } + pkgs = append(pkgs, pkgID) + if cd := orderContrDID(raw); cd != "" { + contrDIDs = append(contrDIDs, cd) + } + xmls = append(xmls, string(decodeCP1251(raw))) + } + if len(pkgs) == 0 { + if firstErr != nil { + return nil, nil, "", firstErr + } + return nil, nil, "", fmt.Errorf("не удалось отправить ни одного поручения 16") + } + orderXML = strings.Join(xmls, "\n\n") + deps.store.SetSubmitted(guid, contrDIDs, strings.Join(pkgs, ", "), orderXML) + go depoOnOrderSent(globalRC, guid, contrDIDs, "16 (списание)") + return pkgs, contrDIDs, orderXML, firstErr // firstErr — если часть не ушла +} + +// --- HTTP --- + +// registerOutgoing регистрирует ТОЛЬКО действия по депозитарному поручению 16 +// (подача / скачивание / выгрузка). Отдельной страницы нет — блок формы 16 +// показывается внутри карточки исходящего перевода (заявки), как 16/1 у входящих. +// Все действия адресуются по GUID сделки; после подачи возвращаемся на ?back. +func registerOutgoing(mux *http.ServeMux, a *admin, deps outgoingDeps) { + mux.HandleFunc("/admin/outgoing/", func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/admin/outgoing/") + switch { + case strings.HasSuffix(p, "/submit-order"): + submitOutgoingOrder(w, r, deps, strings.TrimSuffix(p, "/submit-order")) + case strings.HasSuffix(p, "/order.xml"): + guid := strings.TrimSuffix(p, "/order.xml") + sub, ok := deps.store.Get(guid) + if !ok || strings.TrimSpace(sub.OrderXML) == "" { + http.Error(w, "отправленное поручение 16 не найдено", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="poruchenie-16-`+shortID(guid)+`.xml"`) + _, _ = w.Write(faithfulXMLBytes(sub.OrderXML)) + case strings.HasSuffix(p, "/draft.zip"): + guid := strings.TrimSuffix(p, "/draft.zip") + deal, ok := scanForm16Deal(deps.store, guid) + if !ok || len(deal.Secs) == 0 { + http.Error(w, "черновик формы 16 по этой сделке не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="chernovik-16-`+shortID(guid)+`.zip"`) + zw := zip.NewWriter(w) + for _, s := range deal.Secs { + for _, fp := range []string{s.File, strings.TrimSuffix(s.File, filepath.Ext(s.File)) + ".SGN"} { + if b, e := os.ReadFile(fp); e == nil { + if wf, e2 := zw.Create(filepath.Base(fp)); e2 == nil { + _, _ = wf.Write(b) + } + } + } + } + zw.Close() + case strings.HasSuffix(p, "/ms010.zip"): + guid := strings.TrimSuffix(p, "/ms010.zip") + sub, ok := deps.store.Get(guid) + if !ok { + http.Error(w, "исходящее поручение не найдено", http.StatusNotFound) + return + } + files := collectOutgoingResponseRaw(sub.ContrDIDs) + if len(files) == 0 { + http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="otvet-nrd-ms010-`+shortID(guid)+`.zip"`) + zw := zip.NewWriter(w) + for _, f := range files { + if wf, e := zw.Create(f.Name); e == nil { + _, _ = wf.Write(f.Data) + } + } + zw.Close() + case strings.HasSuffix(p, "/export.zip"): + exportOutgoing(w, r, deps, strings.TrimSuffix(p, "/export.zip")) + default: + http.NotFound(w, r) + } + }) +} + +// collectOutgoingResponseRaw собирает сырые файлы ответов НРД по всем поручениям +// сделки (по каждому contr_d_id), дедуп по имени файла. +func collectOutgoingResponseRaw(contrDIDs []string) []respFile { + seen := map[string]bool{} + var out []respFile + for _, cd := range contrDIDs { + for _, rf := range collectOrderResponseRaw(cd) { + if seen[rf.Name] { + continue + } + seen[rf.Name] = true + out = append(out, rf) + } + } + return out +} + +func submitOutgoingOrder(w http.ResponseWriter, r *http.Request, deps outgoingDeps, guid string) { + back := r.FormValue("back") + if back == "" { + back = "/admin/claims" + } + sep := "?" + if strings.Contains(back, "?") { + sep = "&" + } + redirect := func(msg string) { + http.Redirect(w, r, back+sep+"flash="+url.QueryEscape(msg), http.StatusSeeOther) + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + pkgs, _, _, err := submitOutgoingFor(ctx, deps, guid) + if len(pkgs) == 0 { + msg := "не удалось" + if err != nil { + msg = err.Error() + } + redirect("Поручение 16 (ORDXM): отправка в НРД не удалась: " + msg) + return + } + note := fmt.Sprintf("Поручение 16 (списание, тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+ + "Ждём встречный 16/1 контрагента и отчёт MS010 о проведённом списании.", strings.Join(pkgs, ", ")) + if err != nil { + note += " ⚠ часть файлов не ушла: " + err.Error() + } + redirect(note) +} diff --git a/internal/lkgateway/quik_live_test.go b/internal/lkgateway/quik_live_test.go new file mode 100644 index 0000000..e29a099 --- /dev/null +++ b/internal/lkgateway/quik_live_test.go @@ -0,0 +1,59 @@ +//go:build live + +package lkgateway + +// Живой прогон цикла QUIK МНП на боевом модуле (клиент BR859, 1 шт. T). +// Запуск: go test -tags live -run TestQuikLiveCycle ./internal/lkgateway/ -v +// Требует Quik.WritesEnabled=true в setup.json (тест включает временно и возвращает). + +import ( + "context" + "testing" + "time" +) + +func TestQuikLiveCycle(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + q := rc.Snapshot().Quik + was := q.WritesEnabled + q.WritesEnabled = true + if err := rc.UpdateQuik(q); err != nil { + t.Fatal(err) + } + defer func() { q := rc.Snapshot().Quik; q.WritesEnabled = was; _ = rc.UpdateQuik(q) }() + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + defer cancel() + guid := "LIVETEST-" + time.Now().Format("150405") + + code, contract, err := quikResolveClient(ctx, rc, "4500", "000000") + t.Logf("resolve (неизвестный паспорт): code=%q contract=%q err=%v", code, contract, err) + + in, out, err := quikM2MInstruments(ctx, rc.Snapshot().Quik) + t.Logf("инструменты: ввод=%s вывод=%s err=%v", in, out, err) + + // +1: Ввод ЦБ (M2M) + l, err := QuikInDeposit(ctx, rc, guid, "BR859", "БР-859", "RU000A107UL4", 1, "7722061076", "Депозитарий-контрагент (тест M2M)") + if err != nil { + t.Fatalf("deposit: %v", err) + } + t.Logf("deposit: state=%s instr=%s steps=%d", l.State, l.InstrNo, len(l.Steps)) + + // -1: Вывод ЦБ (M2M) → подпись + l, err = QuikOutRegister(ctx, rc, guid, "BR859", "БР-859", "RU000A107UL4", 1, "7722061076", "", true) + if err != nil { + t.Fatalf("register: %v", err) + } + t.Logf("register: state=%s instr=%s", l.State, l.InstrNo) + l, err = QuikOutSign(ctx, rc, guid, "RU000A107UL4", "LIVETEST: подпись") + if err != nil { + t.Fatalf("sign: %v", err) + } + t.Logf("sign: state=%s instr=%s", l.State, l.InstrNo) + for _, x := range globalQuikLinks.ForGUID(guid) { + t.Logf("link %s/%s: %s (%s) steps=%d", x.Direction, x.ISIN, x.State, x.StateRu(), len(x.Steps)) + } +} diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go new file mode 100644 index 0000000..325bef7 --- /dev/null +++ b/internal/lkgateway/quikcycle.go @@ -0,0 +1,621 @@ +package lkgateway + +// Автоматический цикл M2M в QUIK МНП. Проверено на боевом МНП 08–09.09.2026: +// +// исходящий перевод (мы списываем): quikOutRegister → [НРД] → quikOutSign | quikOutDeny +// входящий перевод (мы зачисляем): quikInDeposit (регистрируется и исполняется сразу) +// +// Все пишущие вызовы разрешены только при Quik.WritesEnabled. Каждый шаг +// фиксируется в журнале связок (quik-m2m.json) по GUID перевода, ошибки — +// в ленту новостей, чтобы оператор видел, где цикл остановился. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +const ( + quikLinkFile = "/var/lib/bj/.bj/quik-m2m.json" + quikActionWait = 90 * time.Second + quikDefaultAcct = "L01+00000F00" + quikInstrCacheTTL = 10 * time.Minute +) + +// ---- журнал связок «перевод ↔ поручение МНП» ---- + +type QuikStep struct { + At time.Time `json:"at"` + Action string `json:"action"` // register | sign | deny | deposit | recipient + ActionID int64 `json:"action_id,omitempty"` + Status int `json:"status,omitempty"` // QueryStatus МНП + Reply string `json:"reply,omitempty"` + OK bool `json:"ok"` +} + +type QuikLink struct { + GUID string `json:"guid"` + Direction string `json:"direction"` // out | in + ClientID string `json:"client_id"` + Contract string `json:"contract,omitempty"` // номер портфеля DEPO (БР-859) + ISIN string `json:"isin"` + Qty float64 `json:"qty"` + InstrNo string `json:"instr_no,omitempty"` // номер поручения в МНП + State string `json:"state"` // registered | signed | denied | deposited | error | manual + Err string `json:"err,omitempty"` + Steps []QuikStep `json:"steps"` + UpdatedAt time.Time `json:"updated_at"` +} + +// QuikStateRu — статус связки по-русски (для карточек). +func (l *QuikLink) StateRu() string { + if l == nil { + return "нет" + } + switch l.State { + case "registered": + return "зарегистрировано, бумаги заблокированы" + case "signed": + return "исполнено, бумаги списаны" + case "denied": + return "снято, блокировка снята" + case "deposited": + return "исполнено, бумаги зачислены" + case "error": + return "ошибка" + case "manual": + return "автоматика выключена" + } + return l.State +} + +type QuikLinkStore struct { + mu sync.Mutex + path string + links map[string]*QuikLink +} + +var globalQuikLinks = newQuikLinkStore(quikLinkFile) + +func newQuikLinkStore(path string) *QuikLinkStore { + s := &QuikLinkStore{path: path, links: map[string]*QuikLink{}} + if b, err := os.ReadFile(path); err == nil { + var list []*QuikLink + if json.Unmarshal(b, &list) == nil { + for _, l := range list { + s.links[quikKey(l.GUID, l.ISIN)] = l + } + } + } + return s +} + +func (s *QuikLinkStore) saveLocked() { + list := make([]*QuikLink, 0, len(s.links)) + for _, l := range s.links { + list = append(list, l) + } + sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) }) + b, _ := json.MarshalIndent(list, "", " ") + _ = os.MkdirAll(filepath.Dir(s.path), 0o755) + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o640); err == nil { + _ = os.Rename(tmp, s.path) + } +} + +func quikKey(guid, isin string) string { + return strings.ToUpper(strings.TrimSpace(guid)) + "|" + strings.ToUpper(strings.TrimSpace(isin)) +} + +func (s *QuikLinkStore) Get(guid, isin string) *QuikLink { + s.mu.Lock() + defer s.mu.Unlock() + if l, ok := s.links[quikKey(guid, isin)]; ok { + cp := *l + cp.Steps = append([]QuikStep(nil), l.Steps...) + return &cp + } + return nil +} + +// ForGUID — все связки перевода (по бумаге на каждую). +func (s *QuikLinkStore) ForGUID(guid string) []*QuikLink { + s.mu.Lock() + defer s.mu.Unlock() + g := strings.ToUpper(strings.TrimSpace(guid)) + var out []*QuikLink + for _, l := range s.links { + if strings.ToUpper(l.GUID) == g { + cp := *l + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ISIN < out[j].ISIN }) + return out +} + +// ByInstrNo — связка по номеру поручения МНП. +func (s *QuikLinkStore) ByInstrNo(no string) *QuikLink { + s.mu.Lock() + defer s.mu.Unlock() + for _, l := range s.links { + if l.InstrNo == no && no != "" { + cp := *l + return &cp + } + } + return nil +} + +func (s *QuikLinkStore) List(limit int) []*QuikLink { + s.mu.Lock() + defer s.mu.Unlock() + list := make([]*QuikLink, 0, len(s.links)) + for _, l := range s.links { + cp := *l + list = append(list, &cp) + } + sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) }) + if limit > 0 && len(list) > limit { + list = list[:limit] + } + return list +} + +func (s *QuikLinkStore) update(guid, isin string, fn func(l *QuikLink)) *QuikLink { + s.mu.Lock() + defer s.mu.Unlock() + key := quikKey(guid, isin) + l, ok := s.links[key] + if !ok { + l = &QuikLink{GUID: guid, ISIN: isin} + s.links[key] = l + } + fn(l) + l.UpdatedAt = time.Now() + s.saveLocked() + cp := *l + return &cp +} + +// ---- низкий уровень: процедуры МНП (параметры, проверенные на боевом МНП) ---- + +var ( + quikInstrMu sync.Mutex + quikInstrIn string + quikInstrOut string + quikInstrLoaded time.Time +) + +// quikM2MInstruments — коды МНП-инструментов для классов INSTRINM2M / INSTROUTM2M. +// Администратор может назвать их не по документу (у нас: INSTRINM2M/INSTROUTM2M +// вместо RURINM2M/RUROUTM2M), поэтому берём из справочника, настройка — приоритетнее. +func quikM2MInstruments(ctx context.Context, s QuikMNPSettings) (in, out string, err error) { + quikInstrMu.Lock() + defer quikInstrMu.Unlock() + if time.Since(quikInstrLoaded) < quikInstrCacheTTL && quikInstrIn != "" && quikInstrOut != "" { + in, out = quikInstrIn, quikInstrOut + } else { + conn, e := quikOpen(s) + if e != nil { + return "", "", e + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + rows, e := conn.QueryContext(cctx, `SELECT ic.ClassCode, sec.SecCode FROM ISecurities sec JOIN IClasses ic ON ic.ClassId=sec.ClassId + WHERE sec.Enable=1 AND ic.ClassCode IN ('INSTRINM2M','INSTROUTM2M') ORDER BY sec.SecurityId`) + if e != nil { + return "", "", fmt.Errorf("quik: справочник инструментов M2M: %s", quikErrorDetail(e)) + } + defer rows.Close() + for rows.Next() { + var cls, sec string + if rows.Scan(&cls, &sec) == nil { + if cls == "INSTRINM2M" && quikInstrIn == "" { + quikInstrIn = sec + } + if cls == "INSTROUTM2M" && quikInstrOut == "" { + quikInstrOut = sec + } + } + } + quikInstrLoaded = time.Now() + in, out = quikInstrIn, quikInstrOut + } + if strings.TrimSpace(s.SecIn) != "" { + in = strings.TrimSpace(s.SecIn) + } + if strings.TrimSpace(s.SecOut) != "" { + out = strings.TrimSpace(s.SecOut) + } + if in == "" { + in = quikSecDeposit + } + if out == "" { + out = quikSecWithdraw + } + return in, out, nil +} + +func quikAccount(s QuikMNPSettings) string { + if a := strings.TrimSpace(s.Account); a != "" { + return a + } + return quikDefaultAcct +} + +// quikEnsureRecipient — депозитарий-контрагент должен быть в M2MDepoRecipients (иначе +// «Вывод ЦБ (M2M)» не примет ИНН). Добавление идемпотентно (@Operation=1 = добавить/изменить). +func quikEnsureRecipient(ctx context.Context, s QuikMNPSettings, inn, name string) error { + inn = strings.TrimSpace(inn) + if inn == "" { + return errors.New("quik: не задан ИНН депозитария-контрагента") + } + conn, err := quikOpen(s) + if err != nil { + return err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + var n int + if err := conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM M2MDepoRecipients WHERE INN=@p1", inn).Scan(&n); err == nil && n > 0 { + return nil + } + if strings.TrimSpace(name) == "" { + name = "Депозитарий ИНН " + inn + } + if len([]rune(name)) > 128 { + name = string([]rune(name)[:128]) + } + if _, err := conn.ExecContext(cctx, "exec dbo.imp_M2MDepoRecipients @Operation=1, @INN=@inn, @Name=@nm", sql.Named("inn", inn), sql.Named("nm", name)); err != nil { + return fmt.Errorf("quik: imp_M2MDepoRecipients: %s", quikErrorDetail(err)) + } + return nil +} + +// quikWaitAction ждёт окончательного ответа модуля по заявке (QueryStatus 2/3/4); +// 1 и 5 — промежуточные («новая», «проверяется на сервере Quik»). +func quikWaitAction(ctx context.Context, s QuikMNPSettings, actionID int64, d time.Duration) QuikActionResult { + deadline := time.Now().Add(d) + var last QuikActionResult + for { + r, err := quikActionResult(ctx, s, actionID) + if err == nil { + last = r + if r.QueryStatus == 2 || r.QueryStatus == 3 || r.QueryStatus == 4 { + return r + } + } + if time.Now().After(deadline) || ctx.Err() != nil { + if last.ReplyText == "" { + last.ReplyText = "нет ответа модуля за отведённое время" + } + return last + } + time.Sleep(5 * time.Second) + } +} + +const quikNewInstrSQL = `exec dbo.sp_NewInstrSec @ActionID=@aid output, @ExtID=@ext, @ClientID=@cid, @ClientBuyID=NULL, @InstructionType=@it, @AgreeServ=NULL, + @Volume=@vol, @ValFee=NULL, @ValDepoPay=NULL, @ValCashPay=NULL, @PayNumber=NULL, @QuikClassCode=NULL, @OutSecCode=@out, @InSecCode=NULL, @QuikFirm=@firm, + @Account=@acc, @AccountIn=NULL, @CanSplit=0, @BlockS=@vol, @Place=NULL, @DepoAccount_1=NULL, @DepoAccount_2=NULL, @DepoAccount_3=NULL, @DepoAccount_4=NULL, @PutReason=NULL, + @DealCurr=NULL, @DealSum=NULL, @Payer=NULL, @CP=NULL, @Country=NULL, @DepoCP=NULL, @FirmID=@firm, @SignFlag=NULL, @Phone=NULL, @Comment=@cm, + @InstrDepoID=NULL, @SubDepoCP=NULL, @IDDocTypeCP=NULL, @IDDocNumCP=NULL, @IDDocDateCP=NULL, @IDDocIssuerCP=NULL, @CPType=NULL, @Responsible=NULL, @Scale=NULL, @InstrFlags=NULL, + @DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=NULL, @InstrTypeID=@itid, @M2MDepoRecipientINN=@inn, @PassExpensesInfo=@pass, @ISIN=@isin, @QtyScale=@qs` + +// quikExecNewInstr — регистрация поручения по ISIN (ввод или вывод M2M). +func quikExecNewInstr(ctx context.Context, s QuikMNPSettings, withdraw bool, extID, clientID, isin string, qty float64, inn string, passExpenses bool, comment string) (int64, error) { + if !s.WritesEnabled { + return 0, errQuikWritesDisabled + } + secIn, secOut, err := quikM2MInstruments(ctx, s) + if err != nil { + return 0, err + } + itype, itid, out := quikBaseTypeDeposit, quikTypeDepositM2M, secIn + if withdraw { + itype, itid, out = quikBaseTypeWithdraw, quikTypeWithdrawM2M, secOut + } + qs := 0 + if qty != float64(int64(qty)) { + qs = 2 + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + var aid sql.NullInt64 + _, err = conn.ExecContext(cctx, quikNewInstrSQL, + sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("ext", nullStr(extID)), sql.Named("cid", clientID), sql.Named("it", itype), + sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID), sql.Named("acc", quikAccount(s)), + sql.Named("cm", comment), sql.Named("itid", itid), sql.Named("inn", nullStr(inn)), sql.Named("pass", passExpenses), sql.Named("isin", isin), sql.Named("qs", qs)) + if err != nil { + return 0, fmt.Errorf("quik: sp_NewInstrSec: %s", quikErrorDetail(err)) + } + return aid.Int64, nil +} + +// quikExecSign — подпись «Депозитарий» (единственная у «Вывод ЦБ (M2M)») → исполнение. +func quikExecSign(ctx context.Context, s QuikMNPSettings, queryNo float64, clientID string, qty float64, comment string) (int64, error) { + if !s.WritesEnabled { + return 0, errQuikWritesDisabled + } + _, secOut, err := quikM2MInstruments(ctx, s) + if err != nil { + return 0, err + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + var aid sql.NullInt64 + _, err = conn.ExecContext(cctx, `exec dbo.sp_SignInstrSec @ActionID=@aid output, @QueryNo=@no, @ClientID=@cid, @Volume=@vol, @ValFee=NULL, @ValDepoPay=NULL, @ValCashPay=NULL, + @IntSecCode=@isec, @BlockS=NULL, @PutReason=NULL, @FirmID=@firm, @SignFlag=@sf, @DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=@cm, + @AccountIn=NULL, @ClassCodeAfterTrans=NULL, @SecCodeAfterTrans=NULL, @QtyAfterTrans=NULL, @FirmAfterTrans=NULL, @BrokerComment=NULL`, + sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("cid", clientID), sql.Named("vol", qty), sql.Named("isec", secOut), + sql.Named("firm", quikFirmID), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment)) + if err != nil { + return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err)) + } + return aid.Int64, nil +} + +// quikExecDeny — снятие поручения с причиной (блокировка снимается, клиент видит причину). +func quikExecDeny(ctx context.Context, s QuikMNPSettings, queryNo float64, reason string) (int64, error) { + if !s.WritesEnabled { + return 0, errQuikWritesDisabled + } + reason = strings.TrimSpace(reason) + if reason == "" { + reason = "Отказ по результату перевода M2M" + } + if len([]rune(reason)) > 250 { + reason = string([]rune(reason)[:250]) + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + var aid sql.NullInt64 + _, err = conn.ExecContext(cctx, `exec dbo.sp_DenyInstr @ActionID=@aid output, @QueryNo=@no, @CancelText=@txt, @CheckType=1, @TemplateCodeCommentDeny=NULL`, + sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("txt", reason)) + if err != nil { + return 0, fmt.Errorf("quik: sp_DenyInstr: %s", quikErrorDetail(err)) + } + return aid.Int64, nil +} + +// ---- код клиента QUIK по клиенту DEPO ---- + +var quikPortRe = regexp.MustCompile(`^\s*(\S+)`) + +// quikClientCodeFromPort: «БР-859 Иванов…» → «BR859»; исключения — из настроек. +func quikClientCodeFromPort(s QuikMNPSettings, portNum string) string { + m := quikPortRe.FindStringSubmatch(portNum) + if m == nil { + return "" + } + tok := m[1] + if s.ClientCodeMap != nil { + if v, ok := s.ClientCodeMap[tok]; ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + up := strings.ToUpper(tok) + if strings.HasPrefix(up, "БР-") { + return "BR" + strings.TrimPrefix(up, "БР-") + } + return tok +} + +// quikResolveClient находит в DEPO клиента по паспорту и возвращает код QUIK и номер портфеля. +func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string) (code, contract string, err error) { + st := rc.Snapshot() + db, err := fancyOpen(st.Fancy, st.Fancy.DepoDB()) + if err != nil { + return "", "", fmt.Errorf("DEPO: %w", err) + } + defer db.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "") + numN := strings.TrimSpace(num) + // Правило депозитария: при нескольких договорах клиента зачисление идёт на + // САМЫЙ РАННИЙ договор — берём портфель с наименьшей датой (затем по дате + // открытия счёта и номеру). + rows, err := db.QueryContext(cctx, `SELECT TOP 5 p.PORT_NUM FROM OD_FACES f + JOIN OD_CLIENTS c ON c.FACE=f.ID JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID + WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 + ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`, serN, numN) + if err != nil { + return "", "", fmt.Errorf("DEPO: %w", err) + } + defer rows.Close() + for rows.Next() { + var port string + if rows.Scan(&port) == nil { + if c := quikClientCodeFromPort(st.Quik, port); c != "" { + return c, strings.Fields(port)[0], nil + } + } + } + return "", "", errors.New("клиент с таким паспортом и открытым торговым счётом в DEPO не найден") +} + +// ---- высокоуровневые операции цикла ---- + +func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep { + return QuikStep{At: time.Now(), Action: action, ActionID: aid, Status: r.QueryStatus, Reply: r.ReplyText, OK: ok} +} + +// QuikOutRegister — исходящий перевод: зарегистрировать «Вывод ЦБ (M2M)» (QUIK блокирует бумаги). +func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, recipientINN, recipientName string, passExpenses bool) (*QuikLink, error) { + s := rc.Snapshot().Quik + l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty + }) + if err := quikEnsureRecipient(ctx, s, recipientINN, recipientName); err != nil { + return quikFail(rc, guid, isin, "register", err), err + } + aid, err := quikExecNewInstr(ctx, s, true, shortGUID(guid), clientID, isin, qty, recipientINN, passExpenses, "M2M bj: перевод "+shortGUID(guid)) + if err != nil { + return quikFail(rc, guid, isin, "register", err), err + } + r := quikWaitAction(ctx, s, aid, quikActionWait) + if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" { + err := fmt.Errorf("МНП отверг регистрацию: %s", r.ReplyText) + globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("register", aid, r, false)) }) + return quikFail(rc, guid, isin, "register", err), err + } + l = globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.InstrNo, l.State, l.Err = r.InstrNo, "registered", "" + l.Steps = append(l.Steps, quikStep("register", aid, r, true)) + }) + log.Printf("quik: %s вывод зарегистрирован, поручение %s (%s, %s × %v)", shortGUID(guid), r.InstrNo, clientID, isin, qty) + go depoOnQuik(rc, guid, isin, "register", r.InstrNo) + return l, nil +} + +// QuikOutSign — НРД подтвердил перевод: подписать поручение → исполнено, бумаги списаны. +func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment string) (*QuikLink, error) { + s := rc.Snapshot().Quik + l := globalQuikLinks.Get(guid, isin) + if l == nil || l.InstrNo == "" { + return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") + } + if l.State == "signed" { + return l, nil + } + var no float64 + fmt.Sscan(l.InstrNo, &no) + aid, err := quikExecSign(ctx, s, no, l.ClientID, l.Qty, comment) + if err != nil { + return quikFail(rc, guid, isin, "sign", err), err + } + r := quikWaitAction(ctx, s, aid, quikActionWait) + if r.QueryStatus != 2 { + err := fmt.Errorf("МНП отверг подпись: %s", r.ReplyText) + globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("sign", aid, r, false)) }) + return quikFail(rc, guid, isin, "sign", err), err + } + l = globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.State, l.Err = "signed", "" + l.Steps = append(l.Steps, quikStep("sign", aid, r, true)) + }) + log.Printf("quik: %s поручение %s подписано и исполнено", shortGUID(guid), l.InstrNo) + go depoOnQuik(rc, guid, isin, "sign", l.InstrNo) + return l, nil +} + +// QuikOutDeny — перевод отклонён (контрагентом, НРД или оператором): снять поручение с причиной. +func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason string) (*QuikLink, error) { + s := rc.Snapshot().Quik + l := globalQuikLinks.Get(guid, isin) + if l == nil || l.InstrNo == "" { + return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") + } + if l.State == "denied" || l.State == "signed" { + return l, nil + } + var no float64 + fmt.Sscan(l.InstrNo, &no) + aid, err := quikExecDeny(ctx, s, no, reason) + if err != nil { + return quikFail(rc, guid, isin, "deny", err), err + } + r := quikWaitAction(ctx, s, aid, quikActionWait) + if r.QueryStatus != 2 { + err := fmt.Errorf("МНП отверг снятие: %s", r.ReplyText) + globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("deny", aid, r, false)) }) + return quikFail(rc, guid, isin, "deny", err), err + } + l = globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.State, l.Err = "denied", "" + l.Steps = append(l.Steps, quikStep("deny", aid, r, true)) + }) + log.Printf("quik: %s поручение %s снято: %s", shortGUID(guid), l.InstrNo, reason) + go depoOnQuik(rc, guid, isin, "deny", l.InstrNo) + return l, nil +} + +// QuikInDeposit — входящий перевод принят НРД: зарегистрировать «Ввод ЦБ (M2M)» (исполняется сразу). +func QuikInDeposit(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, senderINN, senderName string) (*QuikLink, error) { + s := rc.Snapshot().Quik + if l := globalQuikLinks.Get(guid, isin); l != nil && l.State == "deposited" { + return l, nil + } + globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "in", clientID, contract, isin, qty + }) + if strings.TrimSpace(senderINN) != "" { + _ = quikEnsureRecipient(ctx, s, senderINN, senderName) + } + aid, err := quikExecNewInstr(ctx, s, false, shortGUID(guid), clientID, isin, qty, senderINN, false, "M2M bj: зачисление "+shortGUID(guid)) + if err != nil { + return quikFail(rc, guid, isin, "deposit", err), err + } + r := quikWaitAction(ctx, s, aid, quikActionWait) + if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" { + err := fmt.Errorf("МНП отверг зачисление: %s", r.ReplyText) + globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Steps = append(l.Steps, quikStep("deposit", aid, r, false)) }) + return quikFail(rc, guid, isin, "deposit", err), err + } + l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.InstrNo, l.State, l.Err = r.InstrNo, "deposited", "" + l.Steps = append(l.Steps, quikStep("deposit", aid, r, true)) + }) + log.Printf("quik: %s ввод зарегистрирован и исполнен, поручение %s (%s, %s × %v)", shortGUID(guid), r.InstrNo, clientID, isin, qty) + go depoOnQuik(rc, guid, isin, "deposit", r.InstrNo) + return l, nil +} + +func quikFail(rc *RuntimeConfig, guid, isin, action string, err error) *QuikLink { + l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.State, l.Err = "error", err.Error() + }) + log.Printf("quik: %s %s: %v", shortGUID(guid), action, err) + title := map[string]string{"register": "QUIK МНП: не удалось зарегистрировать вывод", "sign": "QUIK МНП: не удалось подписать (исполнить) поручение", "deny": "QUIK МНП: не удалось снять поручение", "deposit": "QUIK МНП: не удалось зачислить бумаги"}[action] + raiseSystemAlert(rc, title, "Перевод "+shortGUID(guid)+", бумага "+isin+": "+err.Error()+". Откройте карточку перевода и повторите операцию кнопкой или выполните её в QUIK вручную.") + return l +} + +func shortGUID(g string) string { + g = strings.ToUpper(strings.TrimSpace(g)) + if len(g) > 8 { + return g[:8] + } + return g +} + +// IsTerminal — связка подхвачена из терминала (заявка в bj ещё не создана). +func (l *QuikLink) IsTerminal() bool { return l != nil && strings.HasPrefix(l.GUID, "terminal-") } + +// LastStep — последний шаг журнала (для таблиц). +func (l *QuikLink) LastStep() *QuikStep { + if l == nil || len(l.Steps) == 0 { + return nil + } + return &l.Steps[len(l.Steps)-1] +} diff --git a/internal/lkgateway/quikdb.go b/internal/lkgateway/quikdb.go new file mode 100644 index 0000000..e48b906 --- /dev/null +++ b/internal/lkgateway/quikdb.go @@ -0,0 +1,375 @@ +package lkgateway + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + mssql "github.com/microsoft/go-mssqldb" +) + +// mssql18456States — расшифровка State для ошибки 18456 (Login failed). +// Помогает коллегам понять точную причину отказа логина. +var mssql18456States = map[uint8]string{ + 1: "сервер СКРЫВАЕТ точную причину от внешних клиентов (защита). Реальный State — только в SQL Server ERRORLOG на стороне QUIK. Попросите коллег глянуть ERRORLOG: там будет 8=неверный пароль, 5=нет логина, 38/40=недоступна БД по умолчанию", + 2: "неверный ID пользователя (логина нет)", + 5: "неверный ID пользователя (логина нет)", + 6: "попытка входа SQL-логином, но настроена только Windows-аутентификация", + 7: "логин отключён И неверный пароль", + 8: "НЕВЕРНЫЙ ПАРОЛЬ", + 9: "недопустимый пароль", + 11: "вход разрешён, но доступ к серверу запрещён (нет прав)", + 12: "вход разрешён, но доступ к серверу запрещён (нет прав)", + 18: "требуется смена пароля (пароль истёк)", + 38: "не найдена/недоступна база данных, указанная в подключении", + 40: "не удалось открыть БД по умолчанию для этого логина", + 46: "не найдена БД по умолчанию для логина", +} + +// quikErrorDetail разбирает ошибку подключения и возвращает подробную +// диагностику (для копирования и показа коллегам/аутсорсеру). +func quikErrorDetail(err error) string { + if err == nil { + return "" + } + var b strings.Builder + var me mssql.Error + if errors.As(err, &me) { + fmt.Fprintf(&b, "Ответ сервера MSSQL:\n") + fmt.Fprintf(&b, " Сообщение: %s\n", strings.TrimSpace(me.Message)) + fmt.Fprintf(&b, " Номер ошибки: %d\n", me.Number) + fmt.Fprintf(&b, " State: %d", me.State) + if me.Number == 18456 { + if s, ok := mssql18456States[me.State]; ok { + fmt.Fprintf(&b, " → %s", s) + } else { + fmt.Fprintf(&b, " → (см. таблицу state для 18456)") + } + } + b.WriteByte('\n') + fmt.Fprintf(&b, " Class (severity):%d\n", me.Class) + if me.ServerName != "" { + fmt.Fprintf(&b, " Сервер: %s\n", me.ServerName) + } + if me.ProcName != "" { + fmt.Fprintf(&b, " Процедура: %s\n", me.ProcName) + } + if me.LineNo != 0 { + fmt.Fprintf(&b, " Строка: %d\n", me.LineNo) + } + } + fmt.Fprintf(&b, "Сырая ошибка драйвера:\n %s", err.Error()) + return b.String() +} + +// Коннектор к БД Модуля неторговых поручений QUIK (MSSQL). БД БОЕВАЯ — здесь +// только чтение (версия, метаданные, справочники) и, позже, штатные хранимые +// процедуры API МНП (роль InstrEu). Прямых INSERT/UPDATE в таблицы нет. + +// quikDSN строит ADO-строку подключения (пароль как есть, без URL-кодирования — +// у BROKER_88 пароль со спецсимволами). db="" → без указания БД. +func quikDSN(s QuikMNPSettings) string { + port := s.Port + if port == 0 { + port = 1433 + } + enc := s.Encrypt + if enc == "" { + enc = "true" // боевой сервер обычно требует шифрование + } + parts := []string{ + fmt.Sprintf("server=%s", s.Server), + fmt.Sprintf("port=%d", port), + fmt.Sprintf("user id=%s", s.User), + fmt.Sprintf("password=%s", s.Password), + fmt.Sprintf("encrypt=%s", enc), + "connection timeout=12", + "app name=bj-server", + } + if s.Trust { + parts = append(parts, "TrustServerCertificate=true") + } + if s.Database != "" { + parts = append(parts, fmt.Sprintf("database=%s", s.Database)) + } + return strings.Join(parts, ";") +} + +func quikOpen(s QuikMNPSettings) (*sql.DB, error) { + conn, err := sql.Open("sqlserver", quikDSN(s)) + if err != nil { + return nil, err + } + conn.SetConnMaxLifetime(5 * time.Minute) + conn.SetMaxOpenConns(3) + return conn, nil +} + +// QuikInstruction — неторговое поручение M2M из экспортной таблицы Export. +// Только чтение: bj забирает поручения на вывод ЦБ (M2M), которые клиент подал +// через QUIK/ЛК и по которым бумаги уже заблокированы на позиции клиента. +type QuikInstruction struct { + ExtID string // внешний ID (наш идентификатор, если поручение заводили мы) + InstrID int64 // ID поручения в МНП + Number string // регистрационный номер + Type int // тип поручения + Status int // статус (см. quikStatusName) + StatusName string + ClientID string // код клиента QUIK + AgreeServ string // договор обслуживания (= contract_number, напр. BR200) + ISIN string + SecCode string + ClassCode string + Volume float64 // количество (с 21.4.0 для Ввод/Вывод ЦБ — в ШТУКАХ) + QtyBlocked int // сколько заблокировано + QtyScale int // масштаб (дробные) + Account string // торговый счёт + DepoAccount string // счёт депо (составной) + DepoCP string // счёт депо контрагента (получателя) + SubDepoCP string // раздел счёта контрагента + RecipINN string // M2MDepoRecipientINN — ИНН депозитария получателя + PassExpense bool // PassExpensesInfo — передавать информацию о расходах + CloseIIA bool // AgreeCloseIIA — согласие на закрытие ИИС + Registered string // дата/время регистрации (ГГГГ-ММ-ДД ЧЧ:ММ:СС) + Deny string // причина отказа/снятия (QueryDeny/CommentDeny) +} + +// quikStatusName — человекочитаемый статус поручения МНП. +func quikStatusName(v int) string { + switch v { + case 0: + return "Активно" + case 1: + return "Исполнено" + case 2: + return "Снято" + case 3: + return "Отменено" + case 4: + return "Запрос на отмену" + default: + return fmt.Sprintf("код %d", v) + } +} + +// quikFmtDateTime собирает дату/время из формата МНП (ГГГГММДД + ЧЧММСС). +func quikFmtDateTime(d, t int) string { + if d == 0 { + return "" + } + s := fmt.Sprintf("%02d.%02d.%04d", d%100, (d/100)%100, d/10000) + if t > 0 { + s += fmt.Sprintf(" %02d:%02d:%02d", t/10000, (t/100)%100, t%100) + } + return s +} + +// quikListM2MInstructions читает поручения M2M из Export (ТОЛЬКО SELECT). +// onlyActive=true — только ожидающие обработки (статус «Активно»). +func quikListM2MInstructions(ctx context.Context, s QuikMNPSettings, limit int, onlyActive bool) ([]QuikInstruction, error) { + conn, err := quikOpen(s) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + if limit <= 0 || limit > 500 { + limit = 200 + } + where := "WHERE ISNULL(IsM2MInstruction,0)=1" + if onlyActive { + where += " AND ISNULL(Status,0)=0" + } + q := fmt.Sprintf(`SELECT TOP (%d) + ISNULL(ExtID,''), ISNULL(InstrID,0), ISNULL(Number,0), ISNULL(Type,0), ISNULL(Status,0), + ISNULL(ClientID,''), ISNULL(AgreeServ,''), ISNULL(ISIN,''), ISNULL(SecCode,''), ISNULL(ClassCode,''), + ISNULL(Volume,0), ISNULL(QtyBlocked,0), ISNULL(QtyScale,0), + ISNULL(Account,''), ISNULL(DepoAccount,''), ISNULL(DepoCP,''), ISNULL(SubDepoCP,''), + ISNULL(M2MDepoRecipientINN,''), ISNULL(PassExpensesInfo,0), ISNULL(AgreeCloseIIA,0), + ISNULL(RegisterDate,0), ISNULL(RegisterTime,0), + ISNULL(QueryDeny,''), ISNULL(CommentDeny,'') + FROM Export %s ORDER BY InstrID DESC`, limit, where) + + rows, err := conn.QueryContext(cctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + var out []QuikInstruction + for rows.Next() { + var it QuikInstruction + var num float64 + var rd, rt int + var qd, cd string + if err := rows.Scan(&it.ExtID, &it.InstrID, &num, &it.Type, &it.Status, + &it.ClientID, &it.AgreeServ, &it.ISIN, &it.SecCode, &it.ClassCode, + &it.Volume, &it.QtyBlocked, &it.QtyScale, + &it.Account, &it.DepoAccount, &it.DepoCP, &it.SubDepoCP, + &it.RecipINN, &it.PassExpense, &it.CloseIIA, + &rd, &rt, &qd, &cd); err != nil { + return out, err + } + it.Number = strings.TrimSuffix(fmt.Sprintf("%.0f", num), ".0") + it.StatusName = quikStatusName(it.Status) + it.Registered = quikFmtDateTime(rd, rt) + it.Deny = strings.TrimSpace(qd + " " + cd) + out = append(out, it) + } + return out, rows.Err() +} + +// QuikSecCheck — результат сверки одной бумаги заявки со справочником QUIK. +type QuikSecCheck struct { + ISIN string + Requested float64 + Found bool // бумага есть в справочнике QUIK + SecCode string // код инструмента QUIK (напр. GAZP) + Name string // наименование выпуска по данным QUIK + SecType string // тип бумаги + GosRegNum string + ClassID int + LotSize int + Note string +} + +// QuikCheckResult — итог сверки заявки с данными QUIK (ТОЛЬКО ЧТЕНИЕ). +// Проверяет наличие клиента и бумаг в справочниках QUIK. Остаток бумаг здесь +// не проверяется — он проверяется сервером QUIK при регистрации поручения. +type QuikCheckResult struct { + Contract string + ClientFound bool + ClientID string // код клиента в QUIK (из Contracts) + ClientName string // ФИО по данным QUIK (из ClientInfo) + DepoAccount string // счёт депо клиента в QUIK + Securities []QuikSecCheck + AllSecsFound bool + Summary string +} + +// quikVerifyRequest сверяет заявку (клиент + бумаги) со справочниками QUIK. +// Только SELECT: Contracts, ClientInfo, DepoClientAccounts, QuikSecurities. +func quikVerifyRequest(ctx context.Context, s QuikMNPSettings, contract string, secs []QuikSecCheck) (QuikCheckResult, error) { + res := QuikCheckResult{Contract: contract, AllSecsFound: true} + conn, err := quikOpen(s) + if err != nil { + return res, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + // 1. Клиент по номеру договора. + err = conn.QueryRowContext(cctx, + "SELECT TOP 1 ClientID FROM Contracts WHERE Number=@p1", contract).Scan(&res.ClientID) + if err == nil && res.ClientID != "" { + res.ClientFound = true + _ = conn.QueryRowContext(cctx, + "SELECT TOP 1 ISNULL(FullName,'') FROM ClientInfo WHERE ClientCode=@p1", res.ClientID).Scan(&res.ClientName) + _ = conn.QueryRowContext(cctx, + "SELECT TOP 1 ISNULL(AccountNumber,'') FROM DepoClientAccounts WHERE ClientID=@p1", res.ClientID).Scan(&res.DepoAccount) + } + + // 2. Бумаги по ISIN. + for i := range secs { + sc := &secs[i] + var classID, lot int + var code, name, typ, gos string + e := conn.QueryRowContext(cctx, `SELECT TOP 1 ISNULL(SecCode,''), ISNULL(DepoName,''), + ISNULL(SecTypeDesc,''), ISNULL(GosRegNum,''), ISNULL(ClassId,0), ISNULL(LotSize,0) + FROM QuikSecurities WHERE ISIN=@p1 ORDER BY LotSize`, sc.ISIN). + Scan(&code, &name, &typ, &gos, &classID, &lot) + if e != nil { + sc.Found = false + sc.Note = "нет в справочнике QUIK" + res.AllSecsFound = false + } else { + sc.Found = true + sc.SecCode, sc.Name, sc.SecType, sc.GosRegNum = code, name, typ, gos + sc.ClassID, sc.LotSize = classID, lot + } + } + res.Securities = secs + switch { + case !res.ClientFound && !res.AllSecsFound: + res.Summary = "клиент не найден в QUIK, часть бумаг отсутствует в справочнике" + case !res.ClientFound: + res.Summary = "бумаги в справочнике QUIK есть, но клиент по договору не найден" + case !res.AllSecsFound: + res.Summary = "клиент найден, но часть бумаг отсутствует в справочнике QUIK" + default: + res.Summary = "сверка пройдена: клиент и все бумаги есть в QUIK" + } + return res, nil +} + +// QuikProbeResult — результат тест-подключения к БД МНП QUIK (только чтение). +type QuikProbeResult struct { + OK bool + Version string // @@VERSION (первая строка) + DBName string // DB_NAME() + Login string // SUSER_SNAME() + M2MReady bool // найдены ли ключевые M2M-объекты МНП + Objects map[string]bool + Note string +} + +// quikProbe — тест подключения к БД МНП QUIK. ТОЛЬКО ЧТЕНИЕ: версия, текущая БД, +// логин, наличие ключевых объектов МНП/M2M (Export, M2MDepoRecipients, Contracts, +// класс INSTROUTM2M, хранимые процедуры). Никаких вызовов процедур и записи. +func quikProbe(ctx context.Context, s QuikMNPSettings) (QuikProbeResult, error) { + var r QuikProbeResult + r.Objects = map[string]bool{} + conn, err := quikOpen(s) + if err != nil { + return r, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + if err := conn.PingContext(cctx); err != nil { + return r, err + } + // Версия, текущая БД, логин. + if err := conn.QueryRowContext(cctx, + "SELECT @@VERSION, DB_NAME(), SUSER_SNAME()").Scan(&r.Version, &r.DBName, &r.Login); err != nil { + return r, err + } + if i := strings.IndexAny(r.Version, "\r\n"); i > 0 { + r.Version = strings.TrimSpace(r.Version[:i]) + } + r.OK = true + + // Наличие ключевых таблиц МНП/M2M (read-only метаданные). + tables := []string{"Export", "Export2", "M2MDepoRecipients", "Contracts", "Instructions", "QuikSecurities"} + for _, t := range tables { + var n int + _ = conn.QueryRowContext(cctx, + "SELECT COUNT(*) FROM sys.tables WHERE name=@p1", t).Scan(&n) + r.Objects["table:"+t] = n > 0 + } + // Класс INSTROUTM2M в справочнике классов (по нему регистрируется Вывод ЦБ М2М). + var cls int + _ = conn.QueryRowContext(cctx, + "SELECT COUNT(*) FROM sys.tables WHERE name IN ('IClasses','Classes')").Scan(&cls) + // Ключевые хранимые процедуры API. + procs := []string{"sp_NewInstrSec", "sp_SignInstrSec", "cancel_instruction"} + for _, p := range procs { + var n int + _ = conn.QueryRowContext(cctx, + "SELECT COUNT(*) FROM sys.procedures WHERE name=@p1", p).Scan(&n) + r.Objects["proc:"+p] = n > 0 + } + + r.M2MReady = r.Objects["table:Export"] && r.Objects["table:Contracts"] && r.Objects["proc:sp_NewInstrSec"] + if r.M2MReady { + r.Note = "БД похожа на МНП с поддержкой M2M — ключевые объекты найдены." + } else { + r.Note = "Подключение есть, но не все ключевые объекты МНП/M2M видны под этим логином (проверьте права/имя БД)." + } + return r, nil +} diff --git a/internal/lkgateway/quikdb_handlers.go b/internal/lkgateway/quikdb_handlers.go new file mode 100644 index 0000000..3d83129 --- /dev/null +++ b/internal/lkgateway/quikdb_handlers.go @@ -0,0 +1,225 @@ +package lkgateway + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// registerQuik — маршруты вкладки «QUIK МНП» (подключение к БД Модуля +// неторговых поручений). Только сохранение реквизитов и тест-подключение +// (read-only). БД боевая — запись не делаем. +func (h *setupHandlers) registerQuik(mux *http.ServeMux) { + mux.HandleFunc("/admin/setup/quik", h.saveQuik) + mux.HandleFunc("/admin/setup/quik/test", h.testQuik) + mux.HandleFunc("/admin/quik", h.quikInstructions) + mux.HandleFunc("/admin/setup/quik/cycle", h.saveQuikCycle) +} + +// quikInstructions — GET /admin/quik. Поручения M2M из БД МНП QUIK. +// ТОЛЬКО ЧТЕНИЕ (боевая БД): SELECT из экспортной таблицы Export. +func (h *setupHandlers) quikInstructions(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot().Quik + onlyActive := r.URL.Query().Get("all") == "" + var items []QuikInstruction + var loadErr string + if s.Server == "" { + loadErr = "Подключение к QUIK не настроено — Настройка → QUIK МНП" + } else if list, err := quikListM2MInstructions(r.Context(), s, 200, onlyActive); err != nil { + loadErr = "не удалось прочитать поручения: " + err.Error() + } else { + items = list + } + data := struct { + page + Items []QuikInstruction + Error string + OnlyActive bool + DBName string + Links []*QuikLink + Cycle QuikMNPSettings + Flash string + }{nowPage("Поручения QUIK", "quik"), items, loadErr, onlyActive, s.Database, globalQuikLinks.List(100), s, r.URL.Query().Get("flash")} + render(w, h.tpl.a.quikInstr, data) +} + +func (h *setupHandlers) quikFromForm(r *http.Request) QuikMNPSettings { + cur := h.rc.Snapshot().Quik + port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) + if port == 0 { + port = cur.Port + } + pass := r.FormValue("password") + if pass == "" { + pass = cur.Password // не затираем сохранённый пароль пустым полем + } + enc := strings.TrimSpace(r.FormValue("encrypt")) + if enc == "" { + enc = "true" + } + return QuikMNPSettings{ + Server: strings.TrimSpace(r.FormValue("server")), + Port: port, + Database: strings.TrimSpace(r.FormValue("database")), + User: strings.TrimSpace(r.FormValue("user")), + Password: pass, + Encrypt: enc, + Trust: r.FormValue("trust") != "", + Verified: cur.Verified, + // поля автоматического цикла M2M правятся отдельной формой — не затираем + WritesEnabled: cur.WritesEnabled, AutoOut: cur.AutoOut, AutoIn: cur.AutoIn, PollTerminal: cur.PollTerminal, + Account: cur.Account, SecOut: cur.SecOut, SecIn: cur.SecIn, ClientCodeMap: cur.ClientCodeMap, + } +} + +// saveQuikCycle — POST /admin/setup/quik/cycle. Настройки автоматического цикла M2M в МНП. +func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Quik + s.WritesEnabled = r.FormValue("writes_enabled") != "" + s.AutoOut = r.FormValue("auto_out") != "" + s.AutoIn = r.FormValue("auto_in") != "" + s.PollTerminal = r.FormValue("poll_terminal") != "" + s.Account = strings.TrimSpace(r.FormValue("account")) + s.SecOut = strings.TrimSpace(r.FormValue("sec_out")) + s.SecIn = strings.TrimSpace(r.FormValue("sec_in")) + m := map[string]string{} + for _, line := range strings.Split(r.FormValue("client_code_map"), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + kv := strings.SplitN(line, "=", 2) + if len(kv) == 2 && strings.TrimSpace(kv[0]) != "" { + m[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + s.ClientCodeMap = m + if err := h.rc.UpdateQuik(s); err != nil { + setupFlash(w, r, "QUIK МНП: ошибка сохранения: "+err.Error()) + return + } + mode := "записи в МНП ВЫКЛЮЧЕНЫ" + if s.WritesEnabled { + mode = fmt.Sprintf("записи включены: исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal)) + } + setupFlash(w, r, "QUIK МНП: цикл M2M сохранён — "+mode) +} + +func onOff(b bool) string { + if b { + return "авто" + } + return "вручную" +} + +// quikClientCodeMapText — карта исключений в виде «ключ=значение» построчно (для формы). +func quikClientCodeMapText(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k + "=" + m[k] + "\n") + } + return b.String() +} + +// saveQuik — POST /admin/setup/quik. Сохраняет реквизиты подключения. +func (h *setupHandlers) saveQuik(w http.ResponseWriter, r *http.Request) { + if !requireAdvanced(w, r) { + return + } + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.quikFromForm(r) + if s.Server == "" || s.User == "" { + setupFlash(w, r, "QUIK МНП: укажите адрес сервера и логин") + return + } + if err := h.rc.UpdateQuik(s); err != nil { + setupFlash(w, r, "QUIK МНП: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, fmt.Sprintf("QUIK МНП: настройки сохранены (%s:%d, логин %s)", s.Server, s.Port, s.User)) +} + +// testQuik — POST /admin/setup/quik/test. Тест-подключение (только чтение). +// Формирует ПОЛНЫЙ отчёт (параметры + ответ сервера) для копирования коллегам. +func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().Quik + if s.Server == "" { + setupFlash(w, r, "QUIK МНП: сначала сохраните реквизиты подключения") + return + } + now := time.Now() + var b strings.Builder + fmt.Fprintf(&b, "=== Тест подключения к БД МНП QUIK ===\n") + fmt.Fprintf(&b, "Время: %s\n", now.Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(&b, "Сервер: %s:%d\n", s.Server, s.Port) + fmt.Fprintf(&b, "БД: %s\n", dfltDB(s.Database)) + fmt.Fprintf(&b, "Логин: %s\n", s.User) + fmt.Fprintf(&b, "Шифрование: encrypt=%s, TrustServerCertificate=%v\n", s.Encrypt, s.Trust) + fmt.Fprintf(&b, "Клиент (bj): исходящий IP 195.239.71.22, драйвер go-mssqldb\n") + fmt.Fprintf(&b, "----------------------------------------\n") + + res, err := quikProbe(r.Context(), s) + if err != nil { + fmt.Fprintf(&b, "РЕЗУЛЬТАТ: ОШИБКА ПОДКЛЮЧЕНИЯ\n\n") + b.WriteString(quikErrorDetail(err)) + s.Verified = false + s.LastReport = b.String() + s.LastAt = now.UTC() + _ = h.rc.UpdateQuik(s) + setupFlash(w, r, "QUIK МНП: подключение не удалось — полный ответ сервера ниже (можно скопировать для коллег)") + return + } + // успех + fmt.Fprintf(&b, "РЕЗУЛЬТАТ: ПОДКЛЮЧЕНИЕ OK\n\n") + fmt.Fprintf(&b, "Версия сервера: %s\n", res.Version) + fmt.Fprintf(&b, "Текущая БД: %s\n", res.DBName) + fmt.Fprintf(&b, "Логин на серв.: %s\n", res.Login) + fmt.Fprintf(&b, "M2M-готовность: %v — %s\n\n", res.M2MReady, res.Note) + fmt.Fprintf(&b, "Объекты МНП/M2M (видимость под логином):\n") + keys := make([]string, 0, len(res.Objects)) + for k := range res.Objects { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + mark := "нет" + if res.Objects[k] { + mark = "ДА" + } + fmt.Fprintf(&b, " %-28s %s\n", k, mark) + } + s.Verified = true + s.LastReport = b.String() + s.LastAt = now.UTC() + _ = h.rc.UpdateQuik(s) + setupFlash(w, r, fmt.Sprintf("QUIK МНП: ПОДКЛЮЧЕНИЕ OK (БД=%s). Полный отчёт ниже.", res.DBName)) +} + +func dfltDB(db string) string { + if db == "" { + return "(по умолчанию для логина)" + } + return db +} diff --git a/internal/lkgateway/quikexport.go b/internal/lkgateway/quikexport.go new file mode 100644 index 0000000..c7ea815 --- /dev/null +++ b/internal/lkgateway/quikexport.go @@ -0,0 +1,1240 @@ +package lkgateway + +// Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj: +// ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний), +// какой объём данных выгружаем (набор таблиц), состояние службы, объём базы, +// свободное место и доступ для внешних систем. Файлы экспортёра принадлежат +// arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер +// (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния +// (systemctl show, размеры логов, статистика базы) bj делает сам. +// +// Лицензионный признак модуля: quik-export. + +import ( + "bufio" + "bytes" + "context" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/text/encoding/charmap" +) + +//go:embed web/quikexport/* +var quikExportFS embed.FS + +const ( + quikExportDefaultDir = "/opt/arqasrv/quik/utils/quikexport" + quikExportStaging = "/var/lib/bj/quik-export-apply" + quikExportKeysDir = "/var/lib/bj/.bj/quik-export/keys" + quikExportFeature = "quik-export" +) + +// QuikExportSettings — настройки модуля (setup.json → quik_export). +type QuikExportSettings struct { + Enabled bool `json:"enabled"` + Dir string `json:"dir"` // каталог службы экспортёра + + // Подключение к серверу QUIK (info.ini [connect] + export.ini [QUIK Server]). + QuikHost string `json:"quik_host"` + QuikPort int `json:"quik_port"` + QuikUser string `json:"quik_user"` + QuikPassword string `json:"quik_password"` + AuthName string `json:"auth_name"` // auth-name из info.ini (выдаёт брокер/ARQA) + + // База данных экспорта. + DBMode string `json:"db_mode"` // local — наш PostgreSQL; external — внешний сервер + DBHost string `json:"db_host"` + DBPort int `json:"db_port"` + DBName string `json:"db_name"` + DBUser string `json:"db_user"` + DBPassword string `json:"db_password"` + ROUser string `json:"ro_user"` // логин только для чтения (для внешних систем и модулей bj) + ROPassword string `json:"ro_password"` + + // Объём данных. + Preset string `json:"preset"` // npr | depo | trading | full | custom + Tables []string `json:"tables"` + PortfolioPeriod int `json:"portfolio_period"` + ParamPeriod int `json:"param_period"` + StatisticPeriod int `json:"statistic_period"` + DebugLevel int `json:"debug_level"` + LimitKinds string `json:"limit_kinds"` + + KeysAt time.Time `json:"keys_at,omitempty"` + KeysNames []string `json:"keys_names,omitempty"` + AppliedAt time.Time `json:"applied_at,omitempty"` + Imported bool `json:"imported"` +} + +func (s QuikExportSettings) withDefaults() QuikExportSettings { + if s.Dir == "" { + s.Dir = quikExportDefaultDir + } + if s.QuikPort == 0 { + s.QuikPort = 15100 + } + if s.DBMode == "" { + s.DBMode = "local" + } + if s.DBHost == "" { + s.DBHost = "localhost" + } + if s.DBPort == 0 { + s.DBPort = 5432 + } + if s.DBName == "" { + s.DBName = "quik_export" + } + if s.DBUser == "" { + s.DBUser = "quik_export" + } + if s.ROUser == "" { + s.ROUser = "quik_ro" + } + if s.Preset == "" { + s.Preset = "npr" + } + if len(s.Tables) == 0 { + s.Tables = quikExportPresetTables(s.Preset) + } + if s.PortfolioPeriod == 0 { + s.PortfolioPeriod = 60 + } + if s.ParamPeriod == 0 { + s.ParamPeriod = 30 + } + if s.StatisticPeriod == 0 { + s.StatisticPeriod = 60 + } + if s.DebugLevel == 0 { + s.DebugLevel = 1 + } + if s.LimitKinds == "" { + s.LimitKinds = "0, 1, 365" + } + return s +} + +// dbHost — хост базы для подключений из bj. +func (s QuikExportSettings) dbHost() string { + if s.DBMode != "external" { + return "localhost" + } + return s.DBHost +} + +// RODSN — строка подключения логина только для чтения (для модуля НПР и внешних систем). +func (s QuikExportSettings) RODSN(withPassword bool) string { + pw := "•••••" + if withPassword { + pw = s.ROPassword + } + return fmt.Sprintf("postgres://%s:%s@%s:%d/%s", s.ROUser, pw, s.dbHost(), s.DBPort, s.DBName) +} + +func (r *RuntimeConfig) UpdateQuikExport(s QuikExportSettings) error { + r.mu.Lock() + r.data.QuikExport = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// ---------- каталог таблиц экспорта ---------- + +// quikExportTable — одна таблица экспортёра ([Output tables] в export.ini). +type quikExportTable struct { + Name string + Title string + Group string + Load string // объём: низкий | средний | высокий | очень высокий + Note string +} + +var quikExportTables = []quikExportTable{ + {"Portfolio", "Клиентский портфель (НПР1/НПР2/УДС, стоимость портфеля, маржа)", "Портфели и лимиты", "средний", "обязателен для модуля НПР/УДС; ≈20 МБ/сутки истории при 5 тыс. клиентов и периоде 60 с"}, + {"DepoLimits", "Лимиты по бумагам (позиции ДЕПО клиентов)", "Портфели и лимиты", "средний", "нужен для доп. проверок M2M: позиция клиента в QUIK перед списанием"}, + {"MoneyLimits", "Денежные лимиты клиентов", "Портфели и лимиты", "средний", ""}, + {"AccountPositions", "Позиции по счетам", "Портфели и лимиты", "низкий", ""}, + {"FuturesHolding", "Позиции на срочном рынке", "Портфели и лимиты", "средний", ""}, + {"FuturesLimits", "Лимиты срочного рынка", "Портфели и лимиты", "низкий", ""}, + {"DepoLimitsJournal", "Журнал изменений лимитов по бумагам", "Портфели и лимиты", "высокий", "растёт постоянно"}, + {"MoneyLimitsJournal", "Журнал изменений денежных лимитов", "Портфели и лимиты", "высокий", "растёт постоянно"}, + {"FuturesHoldingJournal", "Журнал позиций срочного рынка", "Портфели и лимиты", "высокий", ""}, + {"Classes", "Классы инструментов", "Справочники", "низкий", ""}, + {"Securities", "Инструменты (бумаги)", "Справочники", "низкий", "нужен, чтобы сопоставлять ISIN и коды QUIK"}, + {"SecuritiesDictionary", "Справочник инструментов (расширенный)", "Справочники", "низкий", ""}, + {"Firms", "Фирмы", "Справочники", "низкий", ""}, + {"Trdaccs", "Торговые счета", "Справочники", "низкий", ""}, + {"FirmDepoAccount", "Счета ДЕПО фирмы", "Справочники", "низкий", ""}, + {"SecDepoAccount", "Счета ДЕПО по бумагам", "Справочники", "низкий", ""}, + {"Calendars", "Календари торгов", "Справочники", "низкий", ""}, + {"SettleCodes", "Коды расчётов", "Справочники", "низкий", ""}, + {"CurrencyInfo", "Валюты", "Справочники", "низкий", ""}, + {"Prohibitions", "Запреты (ограничения торговли)", "Справочники", "низкий", ""}, + {"Trades", "Сделки клиентов", "Сделки и заявки", "высокий", "все сделки фирмы за день"}, + {"Orders", "Заявки клиентов", "Сделки и заявки", "высокий", ""}, + {"StopOrders", "Стоп-заявки", "Сделки и заявки", "средний", ""}, + {"NegDeals", "Заявки на внебиржевые сделки (РПС)", "Сделки и заявки", "низкий", ""}, + {"NegReports", "Отчёты по внебиржевым сделкам", "Сделки и заявки", "низкий", ""}, + {"UsTrades", "Сделки для исполнения", "Сделки и заявки", "средний", ""}, + {"AlgoOrders", "Алгоритмические заявки", "Сделки и заявки", "средний", ""}, + {"AlgoLinkedOrders", "Связанные алго-заявки", "Сделки и заявки", "средний", ""}, + {"AlgoLinkedTrades", "Сделки алго-заявок", "Сделки и заявки", "средний", ""}, + {"TransResult", "Результаты транзакций", "Сделки и заявки", "средний", ""}, + {"NCCTransfers", "Переводы НКЦ", "Сделки и заявки", "низкий", ""}, + {"AllTrades", "Все обезличенные сделки рынка", "Рыночные данные", "очень высокий", "сотни МБ – ГБ в сутки; не нужен для депозитарных задач"}, + {"Quotes", "Стаканы котировок", "Рыночные данные", "очень высокий", "ГБ в сутки; не нужен"}, + {"Params", "Текущие торги (параметры инструментов)", "Рыночные данные", "высокий", "цены для оценки портфеля; период ParamPeriod"}, + {"CCPHold", "Обеспечение НКЦ", "Рыночные данные", "низкий", ""}, + {"CCPIndicative", "Индикативные курсы НКЦ", "Рыночные данные", "низкий", ""}, + {"CCPPriceRange", "Ценовые коридоры НКЦ", "Рыночные данные", "низкий", ""}, + {"MarketMakerStats", "Статистика маркет-мейкера", "Рыночные данные", "средний", ""}, + {"MarketMakerForts", "Статистика ММ на срочном рынке", "Рыночные данные", "средний", ""}, + {"MarketMakerExtStats", "Расширенная статистика ММ", "Рыночные данные", "средний", ""}, + {"RmHold", "Риск-параметры: обеспечение", "Рыночные данные", "низкий", ""}, + {"RmIndicative", "Риск-параметры: индикативные курсы", "Рыночные данные", "низкий", ""}, + {"RmPriceRange", "Риск-параметры: ценовые коридоры", "Рыночные данные", "низкий", ""}, + {"RmPriceRangeFirm", "Риск-параметры: коридоры по фирме", "Рыночные данные", "низкий", ""}, + {"RmProductSpreadCurr", "Риск-параметры: спреды", "Рыночные данные", "низкий", ""}, + {"SecAccIntEx", "Внутренние переводы бумаг", "Прочее", "низкий", ""}, +} + +var quikExportPresets = []struct{ Key, Title, Desc string }{ + {"npr", "Минимум — только НПР/УДС", "Portfolio. Достаточно для модуля «НПР / УДС». ≈20 МБ/сутки."}, + {"depo", "Депозитарий — портфели, позиции, справочники", "Portfolio + DepoLimits, MoneyLimits, AccountPositions + справочники бумаг и счетов. Даёт bj дополнительные проверки при M2M (позиция клиента в QUIK). ≈50–100 МБ/сутки."}, + {"trading", "Торговля — плюс сделки и заявки", "Всё из «Депозитарий» + Trades, Orders, StopOrders, NegDeals/NegReports, UsTrades, TransResult. Сотни МБ/сутки в активные дни."}, + {"full", "Полный набор", "Все таблицы, включая обезличенные сделки и стаканы. Гигабайты в сутки — только при явной необходимости."}, + {"custom", "Свой набор", "Отметьте таблицы вручную."}, +} + +func quikExportPresetTables(preset string) []string { + depo := []string{"Portfolio", "DepoLimits", "MoneyLimits", "AccountPositions", "Classes", "Securities", "Firms", "Trdaccs", "FirmDepoAccount", "SecDepoAccount", "CurrencyInfo"} + switch preset { + case "depo": + return depo + case "trading": + return append(depo, "Trades", "Orders", "StopOrders", "NegDeals", "NegReports", "UsTrades", "TransResult") + case "full": + out := make([]string, 0, len(quikExportTables)) + for _, t := range quikExportTables { + out = append(out, t.Name) + } + return out + } + return []string{"Portfolio"} +} + +// ---------- сборка ini ---------- + +// iniSet — в тексте ini выставляет key=value в секции section (раскомментирует +// «;key=» при необходимости, иначе добавляет в конец секции). Секция создаётся, +// если её нет. Прочие строки сохраняются как есть. +func iniSet(text, section, key, value string) string { + lines := strings.Split(text, "\n") + inSec, done := false, false + secStart, secEnd := -1, -1 + for i, ln := range lines { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "[") { + if inSec && secEnd < 0 { + secEnd = i + } + inSec = strings.EqualFold(t, "["+section+"]") + if inSec { + secStart = i + } + continue + } + if !inSec || done { + continue + } + k := strings.TrimLeft(t, "; \t") + if eq := strings.Index(k, "="); eq > 0 && strings.EqualFold(strings.TrimSpace(k[:eq]), key) { + lines[i] = key + "=" + value + done = true + } + } + if done { + return strings.Join(lines, "\n") + } + if secStart < 0 { + return strings.TrimRight(text, "\n") + "\n\n[" + section + "]\n" + key + "=" + value + "\n" + } + if secEnd < 0 { + secEnd = len(lines) + } + // вставляем перед пустыми строками в конце секции + ins := secEnd + for ins > secStart+1 && strings.TrimSpace(lines[ins-1]) == "" { + ins-- + } + out := append([]string{}, lines[:ins]...) + out = append(out, key+"="+value) + out = append(out, lines[ins:]...) + return strings.Join(out, "\n") +} + +// iniOutputTables — в секции [Output tables] включает ровно перечисленные таблицы. +func iniOutputTables(text string, enabled []string) string { + on := map[string]bool{} + for _, n := range enabled { + on[n] = true + } + lines := strings.Split(text, "\n") + inSec := false + seen := map[string]bool{} + insertAt := -1 + for i, ln := range lines { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "[") { + if inSec { + insertAt = i + } + inSec = strings.EqualFold(t, "[Output tables]") + continue + } + if !inSec { + continue + } + k := strings.TrimLeft(t, "; \t") + eq := strings.Index(k, "=") + if eq <= 0 { + continue + } + name := strings.TrimSpace(k[:eq]) + seen[name] = true + if on[name] { + lines[i] = name + "=" + strings.TrimSpace(k[eq+1:]) + } else { + lines[i] = ";" + name + "=" + strings.TrimSpace(k[eq+1:]) + } + } + // таблицы, которых нет в шаблоне + var extra []string + for _, n := range enabled { + if !seen[n] { + extra = append(extra, n+"="+n) + } + } + if len(extra) > 0 { + if insertAt < 0 { + insertAt = len(lines) + } + for insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) == "" { + insertAt-- + } + out := append([]string{}, lines[:insertAt]...) + out = append(out, extra...) + out = append(out, lines[insertAt:]...) + lines = out + } + return strings.Join(lines, "\n") +} + +// iniGet — значение ключа в секции (пустая строка, если нет или закомментирован). +func iniGet(text, section, key string) string { + inSec := false + for _, ln := range strings.Split(text, "\n") { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "[") { + inSec = strings.EqualFold(t, "["+section+"]") + continue + } + if !inSec || strings.HasPrefix(t, ";") { + continue + } + if eq := strings.Index(t, "="); eq > 0 && strings.EqualFold(strings.TrimSpace(t[:eq]), key) { + return strings.TrimSpace(t[eq+1:]) + } + } + return "" +} + +// iniEnabledOutputTables — включённые таблицы из [Output tables]. +func iniEnabledOutputTables(text string) []string { + var out []string + inSec := false + for _, ln := range strings.Split(text, "\n") { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "[") { + inSec = strings.EqualFold(t, "[Output tables]") + continue + } + if !inSec || strings.HasPrefix(t, ";") { + continue + } + if eq := strings.Index(t, "="); eq > 0 { + out = append(out, strings.TrimSpace(t[:eq])) + } + } + return out +} + +func cp1251Decode(b []byte) string { + s, err := charmap.Windows1251.NewDecoder().Bytes(b) + if err != nil { + return string(b) + } + return string(s) +} + +func cp1251Encode(s string) []byte { + b, err := charmap.Windows1251.NewEncoder().Bytes([]byte(s)) + if err != nil { + return []byte(s) + } + return b +} + +// quikExportRenderExportINI — export.ini из вшитого шаблона ARQA и настроек. +func quikExportRenderExportINI(s QuikExportSettings) (string, error) { + b, err := quikExportFS.ReadFile("web/quikexport/export.ini.tmpl") + if err != nil { + return "", err + } + t := cp1251Decode(b) + t = iniSet(t, "DB", "DSN", "quik_export") + t = iniSet(t, "DB", "User", s.DBUser) + t = iniSet(t, "DB", "Password", s.DBPassword) + t = iniSet(t, "Service", "name", "ExportService") + t = iniSet(t, "Service", "displayname", "Export Service") + t = iniSet(t, "QUIK Server", "User", s.QuikUser) + t = iniSet(t, "QUIK Server", "Password", s.QuikPassword) + t = iniSet(t, "General", "DebugLevel", strconv.Itoa(s.DebugLevel)) + t = iniSet(t, "General", "StatisticPeriod", strconv.Itoa(s.StatisticPeriod)) + t = iniSet(t, "General", "Memory", "10") + t = iniSet(t, "General", "ParamPeriod", strconv.Itoa(s.ParamPeriod)) + t = iniSet(t, "General", "PortfolioPeriod", strconv.Itoa(s.PortfolioPeriod)) + t = iniSet(t, "General", "UpdateDealLibs", "0") + t = iniSet(t, "General", "LimitKinds", s.LimitKinds) + t = iniSet(t, "Files", "LogDepth", "3") + t = iniOutputTables(t, s.Tables) + return t, nil +} + +func quikExportRenderInfoINI(s QuikExportSettings) (string, error) { + b, err := quikExportFS.ReadFile("web/quikexport/info.ini.tmpl") + if err != nil { + return "", err + } + t := cp1251Decode(b) + t = iniSet(t, "connect", "trade-server-ip-address", s.QuikHost) + t = iniSet(t, "connect", "auth-name", s.AuthName) + t = iniSet(t, "connect", "check-tmo", "150") + t = iniSet(t, "connect", "port", strconv.Itoa(s.QuikPort)) + t = iniSet(t, "connect", "user_name", s.QuikUser) + t = iniSet(t, "connect", "user_password", s.QuikPassword) + t = iniSet(t, "connect", "RememberLogin", "1") + t = iniSet(t, "proxy", "use-proxy", "0") + t = iniSet(t, "transport", "module", "wt_dtcp.dll") + return t, nil +} + +func quikExportRenderODBC(s QuikExportSettings) string { + return fmt.Sprintf("[quik_export]\nDescription = QUIK export to PostgreSQL (%s)\nDriver = PostgreSQL ANSI\nServername = %s\nPort = %d\nDatabase = %s\nConnSettings = SET client_encoding TO 'WIN1251'\nTrace = No\n", + s.DBMode, s.dbHost(), s.DBPort, s.DBName) +} + +// ---------- root-операции ---------- + +func quikExportRootOp(ctx context.Context, action, staging, extra string) (map[string]any, error) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + return runKeymediaOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra}) +} + +func quikExportNewStaging() (string, error) { + if err := os.MkdirAll(quikExportStaging, 0o750); err != nil { + return "", err + } + dir := filepath.Join(quikExportStaging, time.Now().Format("20060102-150405")) + return dir, os.MkdirAll(dir, 0o750) +} + +// quikExportApply — собирает файлы, кладёт в staging и просит root установить их. +func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[string]any, error) { + s := rc.Snapshot().QuikExport.withDefaults() + if s.QuikHost == "" || s.QuikUser == "" { + return nil, fmt.Errorf("не заданы сервер QUIK и логин") + } + if s.DBPassword == "" { + return nil, fmt.Errorf("не задан пароль пользователя базы экспорта") + } + dir, err := quikExportNewStaging() + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + exp, err := quikExportRenderExportINI(s) + if err != nil { + return nil, err + } + inf, err := quikExportRenderInfoINI(s) + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(dir, "export.ini"), cp1251Encode(exp), 0o640); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(dir, "info.ini"), cp1251Encode(inf), 0o640); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(dir, "odbc.ini"), []byte(quikExportRenderODBC(s)), 0o640); err != nil { + return nil, err + } + for _, k := range []string{"pubring.txk", "secring.txk"} { + if b, err := os.ReadFile(filepath.Join(quikExportKeysDir, k)); err == nil { + _ = os.WriteFile(filepath.Join(dir, k), b, 0o640) + } + } + mode := "none" + if restart && s.Enabled { + mode = "restart" + } else if !s.Enabled { + mode = "stop" + } + res, err := quikExportRootOp(ctx, "apply", dir, mode) + if err != nil { + return res, err + } + s.AppliedAt = time.Now() + _ = rc.UpdateQuikExport(s) + return res, nil +} + +// quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки. +func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) { + res, err := quikExportRootOp(ctx, "dump", "", "") + if err != nil { + return "", err + } + s := rc.Snapshot().QuikExport.withDefaults() + dec := func(k string) string { + v, _ := res[k].(string) + b, _ := base64.StdEncoding.DecodeString(v) + return cp1251Decode(b) + } + exp, inf := dec("export.ini"), dec("info.ini") + if exp == "" { + return "", fmt.Errorf("export.ini не найден в каталоге экспортёра") + } + set := func(dst *string, v string) { + if v != "" { + *dst = v + } + } + set(&s.DBUser, iniGet(exp, "DB", "User")) + set(&s.DBPassword, iniGet(exp, "DB", "Password")) + set(&s.QuikUser, iniGet(exp, "QUIK Server", "User")) + set(&s.QuikPassword, iniGet(exp, "QUIK Server", "Password")) + set(&s.LimitKinds, iniGet(exp, "General", "LimitKinds")) + if v, _ := strconv.Atoi(iniGet(exp, "General", "PortfolioPeriod")); v > 0 { + s.PortfolioPeriod = v + } + if v, _ := strconv.Atoi(iniGet(exp, "General", "ParamPeriod")); v > 0 { + s.ParamPeriod = v + } + if v, _ := strconv.Atoi(iniGet(exp, "General", "StatisticPeriod")); v > 0 { + s.StatisticPeriod = v + } + if v, _ := strconv.Atoi(iniGet(exp, "General", "DebugLevel")); v > 0 { + s.DebugLevel = v + } + if tabs := iniEnabledOutputTables(exp); len(tabs) > 0 { + s.Tables = tabs + s.Preset = quikExportDetectPreset(tabs) + } + if inf != "" { + set(&s.QuikHost, iniGet(inf, "connect", "trade-server-ip-address")) + set(&s.AuthName, iniGet(inf, "connect", "auth-name")) + if v, _ := strconv.Atoi(iniGet(inf, "connect", "port")); v > 0 { + s.QuikPort = v + } + set(&s.QuikUser, iniGet(inf, "connect", "user_name")) + set(&s.QuikPassword, iniGet(inf, "connect", "user_password")) + } + if kf, ok := res["keyfiles"].(map[string]any); ok && len(kf) > 0 { + _ = os.MkdirAll(quikExportKeysDir, 0o700) + for name, v := range kf { + if name != "pubring.txk" && name != "secring.txk" { + continue + } + if b, err := base64.StdEncoding.DecodeString(fmt.Sprint(v)); err == nil && len(b) > 0 { + _ = os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600) + } + } + } + if keys, ok := res["keys"].([]any); ok && len(keys) > 0 { + s.KeysNames = nil + for _, k := range keys { + s.KeysNames = append(s.KeysNames, fmt.Sprint(k)) + } + if s.KeysAt.IsZero() { + s.KeysAt = time.Now() + } + } + // Пароли базы: если bj хранит их в db.env (перенос 14.09) — берём оттуда. + if env, err := os.ReadFile("/var/lib/bj/.bj/quik-export/db.env"); err == nil { + for _, ln := range strings.Split(string(env), "\n") { + k, v, ok := strings.Cut(ln, "=") + if !ok { + continue + } + v = strings.Trim(strings.TrimSpace(v), `"'`) + switch strings.TrimSpace(k) { + case "reader_user": + set(&s.ROUser, v) + case "reader_password": + set(&s.ROPassword, v) + case "writer_password": + if s.DBPassword == "" { + s.DBPassword = v + } + } + } + } + s.Imported = true + if err := rc.UpdateQuikExport(s); err != nil { + return "", err + } + return fmt.Sprintf("сервер QUIK %s:%d, пользователь %s, таблиц включено %d (%s), период портфелей %d с", s.QuikHost, s.QuikPort, s.QuikUser, len(s.Tables), s.Preset, s.PortfolioPeriod), nil +} + +func quikExportDetectPreset(tabs []string) string { + same := func(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := map[string]bool{} + for _, x := range a { + m[x] = true + } + for _, x := range b { + if !m[x] { + return false + } + } + return true + } + for _, p := range []string{"npr", "depo", "trading", "full"} { + if same(tabs, quikExportPresetTables(p)) { + return p + } + } + return "custom" +} + +// ---------- состояние ---------- + +type quikExportServiceState struct { + Active string // active | inactive | failed | unknown + Sub string + PID int + Since string + MemoryMB int + Restarts int + Enabled bool + Installed bool +} + +func quikExportServiceStatus(dir string) quikExportServiceState { + st := quikExportServiceState{Active: "unknown"} + if fi, err := os.Stat(filepath.Join(dir, "quikexport")); err == nil && !fi.IsDir() { + st.Installed = true + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "systemctl", "show", "quikexport", "-p", "ActiveState,SubState,MainPID,ActiveEnterTimestamp,MemoryCurrent,NRestarts,UnitFileState", "--no-pager").Output() + if err != nil { + return st + } + for _, ln := range strings.Split(string(out), "\n") { + k, v, _ := strings.Cut(strings.TrimSpace(ln), "=") + switch k { + case "ActiveState": + st.Active = v + case "SubState": + st.Sub = v + case "MainPID": + st.PID, _ = strconv.Atoi(v) + case "ActiveEnterTimestamp": + st.Since = v + case "MemoryCurrent": + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + st.MemoryMB = int(n >> 20) + } + case "NRestarts": + st.Restarts, _ = strconv.Atoi(v) + case "UnitFileState": + st.Enabled = v == "enabled" + } + } + return st +} + +type quikExportFileInfo struct { + Name string + SizeMB float64 + ModTime time.Time + Exists bool +} + +func quikExportFile(path string) quikExportFileInfo { + fi, err := os.Stat(path) + if err != nil { + return quikExportFileInfo{Name: filepath.Base(path)} + } + return quikExportFileInfo{Name: filepath.Base(path), SizeMB: float64(fi.Size()) / (1 << 20), ModTime: fi.ModTime(), Exists: true} +} + +func dirSizeMB(path string) float64 { + var total int64 + _ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + total += info.Size() + } + return nil + }) + return float64(total) / (1 << 20) +} + +type diskInfo struct { + Path string + TotalGB, FreeGB float64 + UsedPct int + OK bool +} + +func diskUsage(path string) diskInfo { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return diskInfo{Path: path} + } + total := float64(st.Blocks) * float64(st.Bsize) + free := float64(st.Bavail) * float64(st.Bsize) + d := diskInfo{Path: path, TotalGB: total / (1 << 30), FreeGB: free / (1 << 30), OK: true} + if total > 0 { + d.UsedPct = int((total - free) / total * 100) + } + return d +} + +// quikExportDBStats — объём базы и скорость роста (логином только для чтения). +type quikExportDBStats struct { + OK bool + Error string + DBSizeMB float64 + Tables []struct{ Name, Size string; Rows int64 } + HistoryRows int64 + HistoryFrom time.Time + Rows24h int64 + GrowthMBDay float64 + LastTick time.Time + TickAgeSec int + Clients int64 + ExporterUser string +} + +var ( + qxPoolMu sync.Mutex + qxPool *pgxpool.Pool + qxPoolDSN string +) + +func quikExportPool(s QuikExportSettings) (*pgxpool.Pool, error) { + dsn := s.RODSN(true) + qxPoolMu.Lock() + defer qxPoolMu.Unlock() + if qxPool != nil && qxPoolDSN == dsn { + return qxPool, nil + } + if qxPool != nil { + qxPool.Close() + qxPool = nil + } + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 3 + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + p, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + qxPool, qxPoolDSN = p, dsn + return p, nil +} + +func quikExportStats(s QuikExportSettings) quikExportDBStats { + st := quikExportDBStats{ExporterUser: s.DBUser} + if s.ROPassword == "" { + st.Error = "не задан пароль логина только для чтения" + return st + } + p, err := quikExportPool(s) + if err != nil { + st.Error = err.Error() + return st + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var size int64 + if err := p.QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size); err != nil { + st.Error = err.Error() + return st + } + st.DBSizeMB = float64(size) / (1 << 20) + rows, err := p.Query(ctx, `SELECT s.schemaname||'.'||s.relname, pg_size_pretty(pg_total_relation_size(s.relid)), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid=s.relid),0) FROM pg_stat_user_tables s ORDER BY pg_total_relation_size(s.relid) DESC LIMIT 8`) + if err == nil { + for rows.Next() { + var t struct{ Name, Size string; Rows int64 } + if rows.Scan(&t.Name, &t.Size, &t.Rows) == nil { + st.Tables = append(st.Tables, t) + } + } + rows.Close() + } + var histBytes int64 + var from *time.Time + if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT min(ts) FROM npr.history), (SELECT count(*) FROM npr.history WHERE ts >= now() - interval '24 hours')`).Scan(&st.HistoryRows, &histBytes, &from, &st.Rows24h); err == nil { + if from != nil { + st.HistoryFrom = *from + } + if st.HistoryRows > 0 { + st.GrowthMBDay = float64(histBytes) / float64(st.HistoryRows) * float64(st.Rows24h) / (1 << 20) + } + } + var tick *time.Time + if err := p.QueryRow(ctx, "SELECT last_tick FROM npr.heartbeat WHERE id=1").Scan(&tick); err == nil && tick != nil { + st.LastTick = *tick + st.TickAgeSec = int(time.Since(*tick).Seconds()) + } + _ = p.QueryRow(ctx, `SELECT count(DISTINCT "ClientCode") FROM "Portfolio"`).Scan(&st.Clients) + st.OK = true + return st +} + +// QuikExportView — данные раздела настроек. +type QuikExportView struct { + S QuikExportSettings + Licensed bool + Service quikExportServiceState + Files []quikExportFileInfo // export.ini, info.ini, ключи + Logs []quikExportFileInfo + LogsMB float64 + QAPIMB float64 + DiskDB diskInfo + DiskExp diskInfo + DB quikExportDBStats + LogTail []string + ErrTail []string + Tables []quikExportTable + Groups []string + Presets []struct{ Key, Title, Desc string } + Enabled map[string]bool + RODSN string + RODSNFull string + ShowRO bool + ServerIP string + Warnings []string +} + +var ( + qxViewMu sync.Mutex + qxViewAt time.Time + qxViewLast QuikExportView +) + +func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView { + qxViewMu.Lock() + defer qxViewMu.Unlock() + if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" { + v := qxViewLast + v.ShowRO = showRO + return v + } + s := rc.Snapshot().QuikExport.withDefaults() + v := QuikExportView{S: s, Licensed: moduleAllowed(rc, quikExportFeature), Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO} + v.Enabled = map[string]bool{} + for _, t := range s.Tables { + v.Enabled[t] = true + } + seen := map[string]bool{} + for _, t := range quikExportTables { + if !seen[t.Group] { + seen[t.Group] = true + v.Groups = append(v.Groups, t.Group) + } + } + v.Service = quikExportServiceStatus(s.Dir) + for _, f := range []string{"export.ini", "info.ini", "pubring.txk", "secring.txk"} { + v.Files = append(v.Files, quikExportFile(filepath.Join(s.Dir, f))) + } + for _, f := range []string{"export.log", "export.err", "quikapi.log"} { + fi := quikExportFile(filepath.Join(s.Dir, "logs", f)) + v.Logs = append(v.Logs, fi) + v.LogsMB += fi.SizeMB + } + v.QAPIMB = dirSizeMB(filepath.Join(s.Dir, "QAPI")) + v.DiskExp = diskUsage(s.Dir) + if s.DBMode != "external" { + v.DiskDB = diskUsage("/var/lib/postgresql") + } + v.DB = quikExportStats(s) + v.LogTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.log"), 12) + v.ErrTail = quikExportTail(filepath.Join(s.Dir, "logs", "export.err"), 6) + v.RODSN = s.RODSN(false) + v.RODSNFull = s.RODSN(true) + v.ServerIP = quikExportServerIP() + if v.Service.Installed && v.Service.Active != "active" && s.Enabled { + v.Warnings = append(v.Warnings, "Служба экспортёра не запущена.") + } + if !v.Service.Installed { + v.Warnings = append(v.Warnings, "Экспортёр ARQA не установлен в "+s.Dir+".") + } + if v.DB.OK && v.DB.TickAgeSec > 3*s.PortfolioPeriod+60 && v.Service.Active == "active" { + v.Warnings = append(v.Warnings, fmt.Sprintf("Данные не обновлялись %d с при периоде %d с — проверьте лог экспортёра (подключение к QUIK, «Вы уже работаете в системе»).", v.DB.TickAgeSec, s.PortfolioPeriod)) + } + if v.LogsMB > 1024 { + v.Warnings = append(v.Warnings, fmt.Sprintf("Логи экспортёра занимают %.0f МБ — ротация настраивается при «Применить настройки».", v.LogsMB)) + } + if v.DiskDB.OK && v.DiskDB.FreeGB < 10 { + v.Warnings = append(v.Warnings, fmt.Sprintf("На диске базы данных осталось %.1f ГБ.", v.DiskDB.FreeGB)) + } + if s.Enabled && !v.Licensed { + v.Warnings = append(v.Warnings, "Лицензия не содержит модуль «Экспорт биржевой информации» — модуль выключен.") + } + qxViewAt, qxViewLast = time.Now(), v + return v +} + +func quikExportInvalidate() { + qxViewMu.Lock() + qxViewAt = time.Time{} + qxViewMu.Unlock() +} + +// quikExportTail — последние n строк лога (cp1251). +func quikExportTail(path string, n int) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + fi, _ := f.Stat() + const chunk = 64 << 10 + off := int64(0) + if fi.Size() > chunk { + off = fi.Size() - chunk + } + b := make([]byte, fi.Size()-off) + if _, err := f.ReadAt(b, off); err != nil && err != io.EOF { + return nil + } + var lines []string + sc := bufio.NewScanner(bytes.NewReader(b)) + sc.Buffer(make([]byte, 1<<20), 1<<20) + for sc.Scan() { + if t := strings.TrimSpace(sc.Text()); t != "" { + lines = append(lines, cp1251Decode([]byte(t))) + } + } + if off > 0 && len(lines) > 0 { + lines = lines[1:] // первая строка может быть обрезана + } + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines +} + +func quikExportServerIP() string { + out, err := exec.Command("hostname", "-I").Output() + if err != nil { + return "" + } + f := strings.Fields(string(out)) + if len(f) == 0 { + return "" + } + return f[0] +} + +// ---------- обработчики ---------- + +func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().QuikExport.withDefaults() + f := r.FormValue + atoi := func(v string, def int) int { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 { + return n + } + return def + } + switch f("part") { + case "quik": + s.QuikHost = strings.TrimSpace(f("quik_host")) + s.QuikPort = atoi(f("quik_port"), 15100) + s.QuikUser = strings.TrimSpace(f("quik_user")) + if p := f("quik_password"); p != "" { + s.QuikPassword = p + } + s.AuthName = strings.TrimSpace(f("auth_name")) + case "db": + s.DBMode = f("db_mode") + if s.DBMode != "external" { + s.DBMode = "local" + } + s.DBHost = strings.TrimSpace(f("db_host")) + s.DBPort = atoi(f("db_port"), 5432) + s.DBName = strings.TrimSpace(f("db_name")) + s.DBUser = strings.TrimSpace(f("db_user")) + if p := f("db_password"); p != "" { + s.DBPassword = p + } + s.ROUser = strings.TrimSpace(f("ro_user")) + if p := f("ro_password"); p != "" { + s.ROPassword = p + } + case "volume": + s.Preset = f("preset") + if s.Preset == "custom" { + s.Tables = nil + for _, t := range r.Form["tables"] { + s.Tables = append(s.Tables, t) + } + if len(s.Tables) == 0 { + s.Tables = []string{"Portfolio"} + } + } else { + s.Tables = quikExportPresetTables(s.Preset) + } + sort.Strings(s.Tables) + s.PortfolioPeriod = atoi(f("portfolio_period"), 60) + if s.PortfolioPeriod < 10 { + s.PortfolioPeriod = 10 + } + s.ParamPeriod = atoi(f("param_period"), 30) + s.DebugLevel = atoi(f("debug_level"), 1) + s.LimitKinds = strings.TrimSpace(f("limit_kinds")) + case "module": + s.Enabled = f("enabled") != "" + } + s = s.withDefaults() + if err := h.rc.UpdateQuikExport(s); err != nil { + setupFlash(w, r, "Экспорт QUIK: ошибка сохранения: "+err.Error()) + return + } + quikExportInvalidate() + setupFlash(w, r, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».") +} + +func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if err := r.ParseMultipartForm(4 << 20); err != nil { + setupFlash(w, r, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error()) + return + } + if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil { + setupFlash(w, r, "Экспорт QUIK: "+err.Error()) + return + } + s := h.rc.Snapshot().QuikExport.withDefaults() + var saved []string + for _, field := range []string{"pubring", "secring"} { + file, hdr, err := r.FormFile(field) + if err != nil { + continue + } + b, err := io.ReadAll(io.LimitReader(file, 1<<20)) + file.Close() + if err != nil || len(b) == 0 { + continue + } + name := field + ".txk" + if err := os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600); err != nil { + setupFlash(w, r, "Экспорт QUIK: запись ключа: "+err.Error()) + return + } + saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b))) + } + if len(saved) == 0 { + setupFlash(w, r, "Экспорт QUIK: файлы ключей не выбраны.") + return + } + s.KeysAt = time.Now() + s.KeysNames = nil + for _, k := range []string{"pubring.txk", "secring.txk"} { + if _, err := os.Stat(filepath.Join(quikExportKeysDir, k)); err == nil { + s.KeysNames = append(s.KeysNames, k) + } + } + _ = h.rc.UpdateQuikExport(s) + quikExportInvalidate() + setupFlash(w, r, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».") +} + +func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + if !moduleAllowed(h.rc, quikExportFeature) { + setupFlash(w, r, "Экспорт QUIK: модуль не входит в лицензию.") + return + } + res, err := quikExportApply(r.Context(), h.rc, true) + quikExportInvalidate() + if err != nil { + setupFlash(w, r, "Экспорт QUIK: применить не удалось — "+err.Error()) + return + } + applied, _ := res["applied"].([]any) + var names []string + for _, a := range applied { + names = append(names, fmt.Sprint(a)) + } + setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"])) +} + +func (h *setupHandlers) importQuikExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + msg, err := quikExportImport(r.Context(), h.rc) + quikExportInvalidate() + if err != nil { + setupFlash(w, r, "Экспорт QUIK: импорт не удался — "+err.Error()) + return + } + setupFlash(w, r, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg) +} + +func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + op := r.FormValue("op") + switch op { + case "start", "stop", "restart", "enable": + default: + setupFlash(w, r, "Экспорт QUIK: неизвестная операция.") + return + } + _, err := quikExportRootOp(r.Context(), "service", "", op) + quikExportInvalidate() + if err != nil { + setupFlash(w, r, "Экспорт QUIK: служба — "+err.Error()) + return + } + setupFlash(w, r, "Экспорт QUIK: операция «"+op+"» выполнена.") +} + +func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().QuikExport.withDefaults() + if s.DBMode == "external" { + setupFlash(w, r, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).") + return + } + if s.DBPassword == "" || s.ROPassword == "" { + setupFlash(w, r, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.") + return + } + dir, err := quikExportNewStaging() + if err != nil { + setupFlash(w, r, "Экспорт QUIK: "+err.Error()) + return + } + defer os.RemoveAll(dir) + for _, f := range []string{"exporter_pg.sql", "npr_migration.sql"} { + b, _ := quikExportFS.ReadFile("web/quikexport/" + f) + _ = os.WriteFile(filepath.Join(dir, f), b, 0o640) + } + dbj, _ := json.Marshal(map[string]string{"db": s.DBName, "user": s.DBUser, "password": s.DBPassword, "ro_user": s.ROUser, "ro_password": s.ROPassword}) + _ = os.WriteFile(filepath.Join(dir, "db.json"), dbj, 0o640) + res, err := quikExportRootOp(r.Context(), "db-init", dir, "") + quikExportInvalidate() + if err != nil { + setupFlash(w, r, "Экспорт QUIK: база — "+err.Error()) + return + } + setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"])) +} + +// sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере. +func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot().QuikExport.withDefaults() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="quik_export_setup.sql"`) + fmt.Fprintf(w, "-- База экспорта QUIK для bj. Выполнить на сервере PostgreSQL %s:%d от суперпользователя.\n", s.DBHost, s.DBPort) + fmt.Fprintf(w, "CREATE ROLE %s LOGIN PASSWORD '<пароль>';\nCREATE ROLE %s LOGIN PASSWORD '<пароль>';\nALTER ROLE %s SET log_min_messages = fatal;\nCREATE DATABASE %s OWNER %s ENCODING 'UTF8' TEMPLATE template0;\n\\c %s %s\n", s.DBUser, s.ROUser, s.DBUser, s.DBName, s.DBUser, s.DBName, s.DBUser) + for _, f := range []string{"exporter_pg.sql", "npr_migration.sql"} { + b, _ := quikExportFS.ReadFile("web/quikexport/" + f) + fmt.Fprintf(w, "\n-- ===== %s =====\n", f) + w.Write(bytes.TrimPrefix(b, []byte{0xEF, 0xBB, 0xBF})) + } + fmt.Fprintf(w, "\nGRANT CONNECT ON DATABASE %s TO %s;\nGRANT USAGE ON SCHEMA public, npr TO %s;\nGRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;\nGRANT SELECT ON ALL TABLES IN SCHEMA npr TO %s;\nALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA public GRANT SELECT ON TABLES TO %s;\nALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA npr GRANT SELECT ON TABLES TO %s;\n", s.DBName, s.ROUser, s.ROUser, s.ROUser, s.ROUser, s.DBUser, s.ROUser, s.DBUser, s.ROUser) +} + +// previewQuikExport — показать export.ini/info.ini, которые будут установлены (пароли скрыты). +func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request) { + s := h.rc.Snapshot().QuikExport.withDefaults() + exp, err := quikExportRenderExportINI(s) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + inf, _ := quikExportRenderInfoINI(s) + mask := func(t string) string { + for _, p := range []string{s.DBPassword, s.QuikPassword} { + if p != "" { + t = strings.ReplaceAll(t, "="+p, "=•••••") + } + } + return t + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp)) +} + +func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) { + mux.HandleFunc("/admin/setup/quikexport", h.saveQuikExport) + mux.HandleFunc("/admin/setup/quikexport/keys", h.uploadQuikExportKeys) + mux.HandleFunc("/admin/setup/quikexport/apply", h.applyQuikExport) + mux.HandleFunc("/admin/setup/quikexport/import", h.importQuikExport) + mux.HandleFunc("/admin/setup/quikexport/service", h.serviceQuikExport) + mux.HandleFunc("/admin/setup/quikexport/dbinit", h.dbInitQuikExport) + mux.HandleFunc("/admin/setup/quikexport/sql", h.sqlQuikExport) + mux.HandleFunc("/admin/setup/quikexport/preview", h.previewQuikExport) +} diff --git a/internal/lkgateway/quikhooks.go b/internal/lkgateway/quikhooks.go new file mode 100644 index 0000000..292222b --- /dev/null +++ b/internal/lkgateway/quikhooks.go @@ -0,0 +1,410 @@ +package lkgateway + +// Точки встраивания цикла QUIK МНП в жизненный цикл переводов M2M. +// +// исходящий: SendBuiltRequest → quikOutgoingBeforeSend (регистрация вывода, блокировка) +// applyIncoming(M2MTD с отказом) → quikOnDecision (снятие по отказанным бумагам) +// applyIncoming(M2MER ERROR) → quikOnServiceError (снятие всех) +// quikCycleLoop: MS010 по нашему поручению 16 → подпись (исполнение) +// входящий: quikCycleLoop: MS010 по нашему поручению 16/1 → «Ввод ЦБ (M2M)» (зачисление) +// терминал: quikCycleLoop (PollTerminal): новые «Вывод ЦБ (M2M)» из QUIK → связка + оповещение + +import ( + "context" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +func quikSecQty(q m2m.Quantity) float64 { + if q.Whole != nil { + return float64(*q.Whole) + } + if q.Fractional != nil { + f, _ := strconv.ParseFloat(strings.ReplaceAll(string(*q.Fractional), ",", "."), 64) + return f + } + return 0 +} + +func quikSecISIN(s m2m.RequestSecurity) string { + if s.SecurityDetails.ISIN != nil { + return strings.TrimSpace(string(*s.SecurityDetails.ISIN)) + } + return "" +} + +// quikOutgoingBeforeSend — перед отправкой M2MTR регистрируем «Вывод ЦБ (M2M)»: +// QUIK проверяет позицию и блокирует бумаги. Если клиент не найден в DEPO — +// отправку не блокируем (оповещаем); если QUIK отверг регистрацию — отправку +// ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). +func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { + q := rc.Snapshot().Quik + if !q.WritesEnabled || !q.AutoOut || req == nil { + return nil + } + guid := string(req.Header.GUID) + inv := req.Data.InvestorInformation + ser := "" + if inv.IdentityDocument.DocumentSeries != nil { + ser = string(*inv.IdentityDocument.DocumentSeries) + } + num := string(inv.IdentityDocument.DocumentNumber) + code, contract, err := quikResolveClient(ctx, rc, ser, num) + if err != nil { + for _, sec := range req.Data.TransferredSecurities.Securities { + if isin := quikSecISIN(sec); isin != "" { + globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.Direction, l.Qty, l.State, l.Err = "out", quikSecQty(sec.Quantity), "error", "клиент QUIK не определён: "+err.Error() + }) + } + } + raiseSystemAlert(rc, "QUIK МНП: клиент не найден в DEPO, бумаги не заблокированы", + fmt.Sprintf("Перевод %s (%s %s): %s. Перевод отправлен в НРД без блокировки в QUIK — заблокируйте вручную.", shortGUID(guid), inv.LastName, inv.FirstName, err)) + return nil + } + inn := string(req.Data.ReceivingDepository.INN) + var done []string + for _, sec := range req.Data.TransferredSecurities.Securities { + isin, qty := quikSecISIN(sec), quikSecQty(sec.Quantity) + if isin == "" || qty <= 0 { + continue + } + if _, err := QuikOutRegister(ctx, rc, guid, code, contract, isin, qty, inn, "", true); err != nil { + for _, d := range done { + _, _ = QuikOutDeny(ctx, rc, guid, d, "Отмена: не удалось заблокировать другую бумагу перевода") + } + return fmt.Errorf("QUIK МНП не заблокировал %s × %v: %w", isin, qty, err) + } + done = append(done, isin) + } + return nil +} + +// quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения. +func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) { + q := rc.Snapshot().Quik + if !q.WritesEnabled || !q.AutoOut || decision == nil { + return + } + guid := string(decision.Header.GUID) + links := globalQuikLinks.ForGUID(guid) + if len(links) == 0 { + return + } + refISIN := map[string]string{} + if deal, err := svc.Repo().GetByGUID(ctx, decision.Header.GUID); err == nil && deal != nil && deal.Request != nil { + for _, sec := range deal.Request.Data.TransferredSecurities.Securities { + refISIN[string(sec.ReferenceID)] = quikSecISIN(sec) + } + } + for _, sec := range decision.Data.Securities { + if sec.TransferDecision.Rejection == nil { + continue + } + isin := refISIN[string(sec.ReferenceID)] + if isin == "" && len(links) == 1 { + isin = links[0].ISIN + } + if l := globalQuikLinks.Get(guid, isin); l != nil && l.State == "registered" { + _, _ = QuikOutDeny(ctx, rc, guid, isin, "Отказ принимающего депозитария: "+rejectCodesLabel(sec.TransferDecision.Rejection.Codes)) + } + } +} + +// quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения. +func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) { + q := rc.Snapshot().Quik + if !q.WritesEnabled || !q.AutoOut { + return + } + for _, l := range globalQuikLinks.ForGUID(guid) { + if l.State == "registered" { + _, _ = QuikOutDeny(ctx, rc, guid, l.ISIN, "Отклонено сервисом MOEX МОСТ: "+reason) + } + } +} + +// orderRespVerdict — есть ли по поручению отчёт MS010 и не отказ ли это. +func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string) { + for _, r := range resps { + if !r.Done { + continue + } + done = true + low := strings.ToLower(r.Text) + if strings.Contains(low, "отказ") || strings.Contains(low, "reject") || strings.Contains(low, "не исполнен") { + return true, true, orderRespSummary(r.Text) + } + } + return done, false, "" +} + +// quikCycleTick — один проход опросчика. +func quikCycleTick(ctx context.Context, rc *RuntimeConfig) { + q := rc.Snapshot().Quik + if !q.WritesEnabled || q.Server == "" { + return + } + // исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие + if q.AutoOut && globalOutgoingStore != nil { + for _, od := range scanForm16Drafts(globalOutgoingStore) { + if !od.Submitted { + continue + } + links := globalQuikLinks.ForGUID(od.GUID) + pending := false + for _, l := range links { + if l.State == "registered" { + pending = true + } + } + if !pending { + continue + } + resps, _ := outgoingResponses(od.ContrDIDs) + done, rejected, text := orderRespVerdict(resps) + if !done { + continue + } + for _, l := range links { + if l.State != "registered" { + continue + } + if rejected { + _, _ = QuikOutDeny(ctx, rc, l.GUID, l.ISIN, "Отказ НРД по поручению депо: "+text) + } else { + _, _ = QuikOutSign(ctx, rc, l.GUID, l.ISIN, "Перевод исполнен НРД (MS010), GUID "+shortGUID(l.GUID)) + } + } + } + } + // входящие: наше поручение 16/1 принято НРД → зачисление в QUIK + if q.AutoIn && globalIncomingStore != nil { + for _, it := range globalIncomingStore.List() { + if !it.OrderSubmitted { + continue + } + need := false + for _, sec := range it.Securities { + if sec.Decision == "confirmed" { + if l := globalQuikLinks.Get(it.GUID, sec.ISIN); l == nil || (l.State != "deposited" && l.State != "error") { + need = true + } + } + } + if !need { + continue + } + done, rejected, _ := orderRespVerdict(findOrderResponses(it.OrderContrDID)) + if !done || rejected { + continue + } + code, contract, err := quikResolveClient(ctx, rc, it.DocSeries, it.DocNumber) + if err != nil { + for _, sec := range it.Securities { + if sec.Decision == "confirmed" { + globalQuikLinks.update(it.GUID, sec.ISIN, func(l *QuikLink) { + l.Direction, l.State, l.Err = "in", "error", "клиент QUIK не определён: "+err.Error() + }) + } + } + raiseSystemAlert(rc, "QUIK МНП: входящий перевод не зачислен — клиент не найден в DEPO", + fmt.Sprintf("Перевод %s (%s %s): %s. Зачислите бумаги в QUIK вручную.", shortGUID(it.GUID), it.InvestorLast, it.InvestorFirst, err)) + continue + } + for _, sec := range it.Securities { + if sec.Decision != "confirmed" { + continue + } + if l := globalQuikLinks.Get(it.GUID, sec.ISIN); l != nil && (l.State == "deposited" || l.State == "error") { + continue + } + qty, _ := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(sec.Quantity), ",", "."), 64) + if qty <= 0 { + continue + } + _, _ = QuikInDeposit(ctx, rc, it.GUID, code, contract, sec.ISIN, qty, it.SenderINN, "Депозитарий "+it.SenderCode) + } + } + } + // поручения, поданные клиентами из QUIK: связка + оповещение оператора + if q.PollTerminal { + items, err := quikListM2MInstructions(ctx, q, 100, true) + if err != nil { + log.Printf("quik: опрос Export: %v", err) + return + } + for _, it := range items { + if it.Number == "" || globalQuikLinks.ByInstrNo(it.Number) != nil { + continue + } + key := "terminal-" + it.Number + globalQuikLinks.update(key, it.ISIN, func(l *QuikLink) { + l.Direction, l.ClientID, l.Contract, l.InstrNo, l.Qty, l.State = "out", it.ClientID, it.AgreeServ, it.Number, it.Volume, "manual" + l.Err = "подано клиентом из QUIK — заявка в bj не создана" + l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: "terminal", Reply: "поручение " + it.Number + " зарегистрировано клиентом из QUIK", OK: true}) + }) + raiseSystemAlert(rc, "QUIK: клиент подал «Вывод ЦБ (M2M)» из терминала", + fmt.Sprintf("Поручение №%s: клиент %s, %s × %v, депозитарий получателя ИНН %s. Бумаги заблокированы в QUIK. Создайте заявку на перевод и привяжите поручение в карточке заявки (страница QUIK МНП).", it.Number, it.ClientID, it.ISIN, it.Volume, it.RecipINN)) + } + } +} + +// quikCycleLoop — фоновый опросчик (30 с). +func (s *Server) quikCycleLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + func() { + defer func() { + if r := recover(); r != nil { + log.Printf("quik: panic в опросчике: %v", r) + } + }() + quikCycleTick(ctx, s.rc) + }() + healthBeat("poll-quik") + } +} + +// ---- ручные операции из карточек ---- + +// registerQuikOps — POST /admin/quik/op: guid, isin, op=register|sign|deny|deposit|link, reason, instr_no. +func registerQuikOps(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { + mux.HandleFunc("/admin/quik/op", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + back := r.FormValue("back") + if back == "" || !strings.HasPrefix(back, "/admin") { + back = r.Header.Get("Referer") + if back == "" { + back = "/admin/quik" + } + } + flash := func(msg string) { + sep := "?" + if strings.Contains(back, "?") { + sep = "&" + } + http.Redirect(w, r, back+sep+"flash="+urlQ(msg), http.StatusSeeOther) + } + guid, isin, op := strings.TrimSpace(r.FormValue("guid")), strings.TrimSpace(r.FormValue("isin")), r.FormValue("op") + q := rc.Snapshot().Quik + if !q.WritesEnabled { + flash("QUIK МНП: записи в МНП выключены (Настройка → QUIK МНП → Автоматический цикл)") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + switch op { + case "link": // привязать поручение, поданное из терминала, к заявке + no := strings.TrimSpace(r.FormValue("instr_no")) + t := globalQuikLinks.ByInstrNo(no) + if t == nil { + flash("QUIK МНП: поручение №" + no + " не найдено среди подхваченных") + return + } + globalQuikLinks.update(guid, isin, func(l *QuikLink) { + l.Direction, l.ClientID, l.Contract, l.InstrNo, l.Qty, l.State, l.Err = "out", t.ClientID, t.Contract, t.InstrNo, t.Qty, "registered", "" + l.Steps = append(l.Steps, QuikStep{At: time.Now(), Action: "link", Reply: "привязано поручение " + no + " из терминала", OK: true}) + }) + flash("QUIK МНП: поручение №" + no + " привязано к переводу") + case "register": + deal, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid)) + if err != nil || deal == nil || deal.Request == nil { + flash("QUIK МНП: заявка не найдена") + return + } + inv := deal.Request.Data.InvestorInformation + ser := "" + if inv.IdentityDocument.DocumentSeries != nil { + ser = string(*inv.IdentityDocument.DocumentSeries) + } + code, contract, err := quikResolveClient(ctx, rc, ser, string(inv.IdentityDocument.DocumentNumber)) + if err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + var qty float64 + for _, sec := range deal.Request.Data.TransferredSecurities.Securities { + if quikSecISIN(sec) == isin { + qty = quikSecQty(sec.Quantity) + } + } + if qty <= 0 { + flash("QUIK МНП: бумага " + isin + " не найдена в заявке") + return + } + if _, err := QuikOutRegister(ctx, rc, guid, code, contract, isin, qty, string(deal.Request.Data.ReceivingDepository.INN), "", true); err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + flash("QUIK МНП: поручение зарегистрировано, бумаги заблокированы (" + code + ", " + isin + ")") + case "sign": + if _, err := QuikOutSign(ctx, rc, guid, isin, "Подписано оператором bj"); err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + flash("QUIK МНП: поручение подписано и исполнено, бумаги списаны") + case "deny": + reason := strings.TrimSpace(r.FormValue("reason")) + if reason == "" { + reason = "Снято оператором депозитария" + } + if _, err := QuikOutDeny(ctx, rc, guid, isin, reason); err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + flash("QUIK МНП: поручение снято, блокировка снята") + case "deposit": + it, ok := globalIncomingStore.Get(guid) + if !ok { + // GUID мог быть передан вместо ID + for _, x := range globalIncomingStore.List() { + if strings.EqualFold(x.GUID, guid) { + it, ok = x, true + } + } + } + if !ok { + flash("QUIK МНП: входящий перевод не найден") + return + } + code, contract, err := quikResolveClient(ctx, rc, it.DocSeries, it.DocNumber) + if err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + var qty float64 + for _, sec := range it.Securities { + if sec.ISIN == isin { + qty, _ = strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(sec.Quantity), ",", "."), 64) + } + } + if qty <= 0 { + flash("QUIK МНП: бумага " + isin + " не найдена во входящем переводе") + return + } + if _, err := QuikInDeposit(ctx, rc, it.GUID, code, contract, isin, qty, it.SenderINN, "Депозитарий "+it.SenderCode); err != nil { + flash("QUIK МНП: " + err.Error()) + return + } + flash("QUIK МНП: бумаги зачислены клиенту " + code) + default: + flash("QUIK МНП: неизвестная операция") + } + }) +} diff --git a/internal/lkgateway/quikm2m.go b/internal/lkgateway/quikm2m.go new file mode 100644 index 0000000..733d0b8 --- /dev/null +++ b/internal/lkgateway/quikm2m.go @@ -0,0 +1,282 @@ +package lkgateway + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Коннектор bj → QUIK МНП, ПИШУЩАЯ сторона M2M (регистрация/подпись/снятие). +// Читающая часть (Export, справочники) — в quikdb.go. +// +// Жизненный цикл вывода ЦБ (M2M), по «Перевод ЦБ в MOEX МОСТ» v1.1: +// +// 1. quikRegisterWithdrawal — sp_NewInstrSec (@InstructionType=9, @InstrTypeID=100, +// @CheckType=1): QUIK проверяет достаточность и БЛОКИРУЕТ бумаги, возвращает @ActionID. +// 2. quikActionResult — GetActionResult(@ActionID): QueryStatus (2 исполнено / 3 отвергнуто / +// 4 не обработано / 5 проверяется), ReplyText, InstrNo (= Number поручения). +// 3. читаем Export (quikListM2MInstructions) → Number активного поручения → в НРД. +// 4а. НРД подтвердил → quikSignWithdrawal (sp_SignInstrSec, @SignFlag=16 «Депозитарий», +// @Volume=NULL): статус «Исполнено», блокировка снята, бумаги списаны. +// 4б. НРД отказал → quikDenyInstruction (sp_DenyInstr, @CancelText=причина): снятие, +// блокировка снимается автоматически. +// 5. приём от др. брокера → quikRegisterDeposit (sp_NewInstrSec, @InstructionType=8, +// @InstrTypeID=101, @InSecCode=NULL, @ISIN): регистрируется И сразу исполняется. +// +// БЕЗОПАСНОСТЬ: БД БОЕВАЯ. Все пишущие вызовы идут с @CheckType=1 (реальная +// транзакция на сервер QUIK) и защищены предохранителем quikWritesAllowed — +// пока типы 100/101 выключены (Disabled=y) и нет тестового клиента, писать нельзя. + +// Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные). +const ( + quikFirmID = "MC0413600000" // наш код фирмы (таблица Firms, FirmId=2) + quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М» + quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М» + quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК) + quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК) + quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода + quikSecDeposit = "RURINM2M" // МНП-инструмент ввода + quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий» + quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка) +) + +// errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока +// администратор не завершил настройку в QAdmin (классы/типы) и не выделен +// безопасный контур/клиент для теста. Снимается через quikWritesAllowed. +var errQuikWritesDisabled = errors.New("quik: пишущие операции M2M отключены (нужны: включение типов 100/101 в QAdmin, классы INSTROUTM2M/INSTRINM2M, тестовый клиент)") + +// quikWritesAllowed — глобальный предохранитель пишущих вызовов к боевой БД QUIK. +// По умолчанию false. Включается осознанно (флаг/настройка), когда контур готов. +var quikWritesAllowed = false + +// QuikWithdrawParams — параметры регистрации поручения «Вывод ЦБ (M2M)». +type QuikWithdrawParams struct { + ExtID string // наш идентификатор заявки (связка с bj_transfer_requests.guid) + ClientID string // код клиента в QUIK + ISIN string // ISIN выводимой бумаги + Quantity float64 // количество (в ШТУКАХ — с 21.4.0) + Account string // торговый счёт клиента + DepoAccount string // счёт депо клиента (составной, при необходимости) + RecipientINN string // ИНН депозитария-получателя (из M2MDepoRecipients) + PassExpenses bool // передавать информацию о расходах (наш CostInfo) + QtyScale int // масштаб дробного количества (0 = целые) +} + +// QuikActionResult — итог обработки команды Модулем (GetActionResult / Actions). +type QuikActionResult struct { + QueryStatus int // 1 новая, 2 исполнена, 3 отвергнута, 4 не обработана, 5 проверяется + ReplyText string // текст ответа/ошибки + InstrNo string // номер поручения (= Export.Number) +} + +// quikStatusResultName — расшифровка QueryStatus (GetActionResult). +func quikStatusResultName(v int) string { + switch v { + case 1: + return "новая" + case 2: + return "исполнена" + case 3: + return "отвергнута" + case 4: + return "не может быть обработана" + case 5: + return "проверяется на сервере QUIK" + } + return fmt.Sprintf("код %d", v) +} + +// quikRegisterWithdrawal регистрирует «Вывод ЦБ (M2M)» — QUIK проверяет +// достаточность и блокирует бумаги. Возвращает @ActionID для GetActionResult. +// НЕ вызывать на боевой БД, пока не снят предохранитель quikWritesAllowed. +func quikRegisterWithdrawal(ctx context.Context, s QuikMNPSettings, p QuikWithdrawParams) (int64, error) { + if !quikWritesAllowed { + return 0, errQuikWritesDisabled + } + if strings.TrimSpace(p.ClientID) == "" || strings.TrimSpace(p.ISIN) == "" || p.Quantity <= 0 { + return 0, errors.New("quik: не заданы ClientID/ISIN/Quantity для вывода") + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var actionID int64 + // @QuikClassCode=NULL (обязательно при @CheckType=1 для чужой фирмы) → поиск + // инструмента по @ISIN; @InSecCode=NULL; количество в штуках (@QtyScale). + // @QuikClassCode и @QtyScale вместе задавать НЕЛЬЗЯ — при непустом ISIN оставляем QtyScale. + _, err = conn.ExecContext(cctx, "sp_NewInstrSec", + sql.Named("ActionID", sql.Out{Dest: &actionID}), + sql.Named("ExtID", nullStr(p.ExtID)), + sql.Named("ClientID", p.ClientID), + sql.Named("InstructionType", quikBaseTypeWithdraw), + sql.Named("InstrTypeID", quikTypeWithdrawM2M), + sql.Named("Volume", p.Quantity), + sql.Named("OutSecCode", quikSecWithdraw), + sql.Named("InSecCode", nil), + sql.Named("QuikClassCode", nil), + sql.Named("ISIN", p.ISIN), + sql.Named("QtyScale", nullInt(p.QtyScale)), + sql.Named("Account", nullStr(p.Account)), + sql.Named("QuikFirm", quikFirmID), + sql.Named("FirmID", quikFirmID), + sql.Named("CanSplit", 0), + sql.Named("BlockS", p.Quantity), + sql.Named("M2MDepoRecipientINN", nullStr(p.RecipientINN)), + sql.Named("PassExpensesInfo", p.PassExpenses), + sql.Named("DepoAccount_1", nullStr(p.DepoAccount)), + sql.Named("CheckType", quikCheckViaAPI), + ) + if err != nil { + return 0, fmt.Errorf("quik: sp_NewInstrSec (вывод): %s", quikErrorDetail(err)) + } + return actionID, nil +} + +// quikRegisterDeposit регистрирует «Ввод ЦБ (M2M)» — зачисление бумаг от другого +// брокера. Поиск инструмента по @ISIN (@InSecCode=NULL). Регистрируется и сразу +// исполняется (у типа «Ввод M2M» подписей нет). +func quikRegisterDeposit(ctx context.Context, s QuikMNPSettings, clientID, isin, account string, qty float64) (int64, error) { + if !quikWritesAllowed { + return 0, errQuikWritesDisabled + } + if strings.TrimSpace(clientID) == "" || strings.TrimSpace(isin) == "" || qty <= 0 { + return 0, errors.New("quik: не заданы ClientID/ISIN/qty для ввода") + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var actionID int64 + _, err = conn.ExecContext(cctx, "sp_NewInstrSec", + sql.Named("ActionID", sql.Out{Dest: &actionID}), + sql.Named("ClientID", clientID), + sql.Named("InstructionType", quikBaseTypeDeposit), + sql.Named("InstrTypeID", quikTypeDepositM2M), + sql.Named("Volume", qty), + sql.Named("OutSecCode", quikSecDeposit), + sql.Named("InSecCode", nil), + sql.Named("QuikClassCode", nil), + sql.Named("ISIN", isin), + sql.Named("Account", nullStr(account)), + sql.Named("QuikFirm", quikFirmID), + sql.Named("FirmID", quikFirmID), + sql.Named("CanSplit", 0), + sql.Named("BlockS", qty), + sql.Named("CheckType", quikCheckViaAPI), + ) + if err != nil { + return 0, fmt.Errorf("quik: sp_NewInstrSec (ввод): %s", quikErrorDetail(err)) + } + return actionID, nil +} + +// quikSignWithdrawal подписывает (исполняет) поручение на вывод после подтверждения +// НРД: статус → «Исполнено», блокировка снимается, бумаги списываются. +// @Volume ВСЕГДА NULL при подписи; @SignFlag=16 (подпись «Депозитарий»). +func quikSignWithdrawal(ctx context.Context, s QuikMNPSettings, queryNo float64, clientID string) (int64, error) { + if !quikWritesAllowed { + return 0, errQuikWritesDisabled + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var actionID int64 + _, err = conn.ExecContext(cctx, "sp_SignInstrSec", + sql.Named("ActionID", sql.Out{Dest: &actionID}), + sql.Named("QueryNo", queryNo), + sql.Named("ClientID", nullStr(clientID)), + sql.Named("Volume", nil), // при подписи всегда NULL + sql.Named("IntSecCode", quikSecWithdraw), + sql.Named("FirmID", quikFirmID), + sql.Named("SignFlag", quikSignFlagDepo), + sql.Named("CheckType", quikCheckViaAPI), + ) + if err != nil { + return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err)) + } + return actionID, nil +} + +// quikDenyInstruction снимает поручение с указанием причины (отказ НРД): блокировка +// снимается автоматически, клиент видит причину в терминале. +func quikDenyInstruction(ctx context.Context, s QuikMNPSettings, queryNo float64, cancelText string) (int64, error) { + if !quikWritesAllowed { + return 0, errQuikWritesDisabled + } + if strings.TrimSpace(cancelText) == "" { + cancelText = "Отказ по результату перевода M2M" + } + conn, err := quikOpen(s) + if err != nil { + return 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var actionID int64 + _, err = conn.ExecContext(cctx, "sp_DenyInstr", + sql.Named("ActionID", sql.Out{Dest: &actionID}), + sql.Named("QueryNo", queryNo), + sql.Named("CancelText", cancelText), + sql.Named("CheckType", quikCheckViaAPI), + ) + if err != nil { + return 0, fmt.Errorf("quik: sp_DenyInstr: %s", quikErrorDetail(err)) + } + return actionID, nil +} + +// quikActionResult читает итог обработки команды Модулем (read-only). Безопасно +// на боевой БД — только SELECT из результата GetActionResult / таблицы Actions. +func quikActionResult(ctx context.Context, s QuikMNPSettings, actionID int64) (QuikActionResult, error) { + var r QuikActionResult + conn, err := quikOpen(s) + if err != nil { + return r, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + // Читаем из таблицы Actions по ActionID (эквивалент GetActionResult). + err = conn.QueryRowContext(cctx, + `SELECT ISNULL(QueryStatus,0), ISNULL(ReplyText,''), ISNULL(CAST(InstrNo AS varchar(32)),'') + FROM Actions WHERE ActionID=@p1`, actionID). + Scan(&r.QueryStatus, &r.ReplyText, &r.InstrNo) + if err != nil { + return r, fmt.Errorf("quik: чтение Actions #%d: %s", actionID, quikErrorDetail(err)) + } + return r, nil +} + +// nullStr → NULL для пустой строки (иначе значение). +func nullStr(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + +// nullInt → NULL для нуля (иначе значение). Для @QtyScale: 0 = не задавать. +func nullInt(v int) any { + if v == 0 { + return nil + } + return v +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 7209400..00caad7 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "sync" "time" ) @@ -20,22 +21,65 @@ type RuntimeConfig struct { // Settings — сериализуемое представление настроек. type Settings struct { - Postgres PostgresSettings `json:"postgres"` - Crypto CryptoSettings `json:"crypto"` - NSD NSDSettings `json:"nsd"` - LK LKSettings `json:"lk"` - CACerts CACertsSettings `json:"ca_certs"` - News NewsSettings `json:"news"` - Update UpdateSettings `json:"update"` - License LicenseSettings `json:"license"` - LastTest *TestRunResult `json:"last_test,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Postgres PostgresSettings `json:"postgres"` + Fancy FancyMSSQLSettings `json:"fancy_mssql"` + Quik QuikMNPSettings `json:"quik_mnp"` + Crypto CryptoSettings `json:"crypto"` + NSD NSDSettings `json:"nsd"` + LK LKSettings `json:"lk"` + CACerts CACertsSettings `json:"ca_certs"` + NSDCerts NSDCertsSettings `json:"nsd_certs"` + News NewsSettings `json:"news"` + Alerts AlertSettings `json:"alerts"` + Incoming IncomingNotifySettings `json:"incoming_notify"` + Update UpdateSettings `json:"update"` + License LicenseSettings `json:"license"` + Branding BrandingSettings `json:"branding"` + NPR NPRSettings `json:"npr"` + QuikExport QuikExportSettings `json:"quik_export"` + Auth AuthSettings `json:"auth"` + LastTest *TestRunResult `json:"last_test,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +// QuikMNPSettings — подключение к БД Модуля неторговых поручений QUIK (MSSQL). +// Используется для блокировки бумаг при выводе (M2M). БД боевая — работаем на +// чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/ +// UPDATE в таблицы НЕ делаем. +type QuikMNPSettings struct { + Server string `json:"server"` // host или host\instance + Port int `json:"port"` // напр. 8849 (нестандартный) + Database string `json:"database"` // имя БД МНП (если требуется) + User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88 + Password string `json:"password"` // пароль + Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой) + Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт) + Verified bool `json:"verified"` // последнее тест-подключение прошло + LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам) + LastAt time.Time `json:"last_at"` // время последнего теста + + // Автоматический цикл M2M в МНП (пишущие операции к боевой БД). + WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП + AutoOut bool `json:"auto_out"` // исходящие: регистрировать «Вывод ЦБ (M2M)» при отправке, подписывать/снимать по ответу + AutoIn bool `json:"auto_in"` // входящие: регистрировать «Ввод ЦБ (M2M)» после приёма поручения НРД + PollTerminal bool `json:"poll_terminal"` // подхватывать поручения, поданные клиентами из QUIK (Export) + Account string `json:"account"` // торговый счёт для регистрации (напр. L01+00000F00) + SecOut string `json:"sec_out"` // код МНП-инструмента вывода (пусто = определить по классу INSTROUTM2M) + SecIn string `json:"sec_in"` // код МНП-инструмента ввода (пусто = по классу INSTRINM2M) + ClientCodeMap map[string]string `json:"client_code_map"` // исключения: номер портфеля DEPO («БР-4-4») → код клиента QUIK } // LicenseSettings — лицензионный ключ (подписанный токен). type LicenseSettings struct { - Key string `json:"key"` // компактный токен payload.signature.keyid - PublicKey string `json:"public_key"` // base64 (если не зашит в бинарь) + Key string `json:"key"` // компактный токен payload.signature.keyid + PublicKey string `json:"public_key,omitempty"` // устарело: ключ проверки зашит в bj, поле не используется + + // Онлайн-лицензирование (licenseclient.go). + InstallID string `json:"install_id,omitempty"` // идентификатор этой установки на сервере лицензий + LastSync time.Time `json:"last_sync,omitempty"` // последнее успешное обращение к серверу лицензий + LastAttempt time.Time `json:"last_attempt,omitempty"` // последняя попытка + LastOK bool `json:"last_ok,omitempty"` + LastMsg string `json:"last_msg,omitempty"` } // UpdateSettings — авто-обновления из артефактории (#18/#20). @@ -55,10 +99,10 @@ type UpdateSettings struct { // doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed) // оператором, но не удалено — лента служит «журналом» для аудита. type NewsSettings struct { - Items []NewsItem `json:"items"` - DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages) - LastDocCheck time.Time `json:"last_doc_check"` - DocCheckResult string `json:"doc_check_result"` + Items []NewsItem `json:"items"` + DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages) + LastDocCheck time.Time `json:"last_doc_check"` + DocCheckResult string `json:"doc_check_result"` } // NewsItem — одно событие в ленте. @@ -76,21 +120,21 @@ type NewsItem struct { // DocSource — страница НРД, которую doc-watcher периодически проверяет. type DocSource struct { - URL string `json:"url"` - Name string `json:"name"` // человекочитаемое имя - LastChecked time.Time `json:"last_checked"` - KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256 + URL string `json:"url"` + Name string `json:"name"` // человекочитаемое имя + LastChecked time.Time `json:"last_checked"` + KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256 } // CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего // УЦ. Список редактируется пользователем; раз в сутки фоновая горутина // перекачивает каждый URL и сохраняет сертификат, если он поменялся. type CACertsSettings struct { - URLs []string `json:"urls"` - AutoUpdate bool `json:"auto_update"` - LastFetch time.Time `json:"last_fetch"` - LastFetchLog string `json:"last_fetch_log"` - FetchedCerts []FetchedCACert `json:"fetched_certs"` + URLs []string `json:"urls"` + AutoUpdate bool `json:"auto_update"` + LastFetch time.Time `json:"last_fetch"` + LastFetchLog string `json:"last_fetch_log"` + FetchedCerts []FetchedCACert `json:"fetched_certs"` } // FetchedCACert — информация о последнем удачно скачанном сертификате. @@ -110,6 +154,53 @@ type PostgresSettings struct { DSN string `json:"dsn"` } +// FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с +// Fancy (очередь запросов из ЛК + ответы «крана») и журнал обмена с НРД. +// Аутентификация — SQL-логин (SQL Server Authentication). +type FancyMSSQLSettings struct { + DBType string `json:"db_type"` // "mssql" | "postgres" (пусто = mssql) + Server string `json:"server"` // host или host\instance + Port int `json:"port"` // 1433 (MSSQL) / 5432 (Postgres) + User string `json:"user"` // логин + Password string `json:"password"` // пароль + Encrypt string `json:"encrypt"` // MSSQL: disable|false|true; Postgres: sslmode + + // Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo): + // bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд. + DepoJobRun bool `json:"depo_job_run"` + DepoJobMode string `json:"depo_job_mode"` // bj | sql-auto | sql-manual (пусто = не выбран) + DepoJobCheck string `json:"depo_job_check"` // результат последней проверки готовности + DepoJobCheckOK bool `json:"depo_job_check_ok"` + DepoJobCheckAt time.Time `json:"depo_job_check_at"` + DepoJobEverySec int `json:"depo_job_every_sec"` + DepoJobProc string `json:"depo_job_proc"` + DepoJobRunAs string `json:"depo_job_run_as,omitempty"` // пользователь базы DEPO для EXECUTE AS процедуры (пусто = SYSDBA) + DepoJobLast string `json:"depo_job_last"` // последний результат (для карточки) + DepoJobLastAt time.Time `json:"depo_job_last_at"` + + // Трансферные таблицы FTV_M2M* в базе DEPO (depotables.go). + DepoDatabase string `json:"depo_database"` // база DEPO; пусто = BJ_DEPO_DATA + IISContractMarker string `json:"iis_contract_marker,omitempty"` // начало названия договора ИИС в DEPO (через запятую); пусто = БРИИС + DepoTablesMode string `json:"depo_tables_mode"` // auto (создаёт bj) | script (администратор СУБД) + DepoTablesCheck string `json:"depo_tables_check"` + DepoTablesCheckOK bool `json:"depo_tables_check_ok"` + DepoTablesCheckAt time.Time `json:"depo_tables_check_at"` +} + +// IsPostgres сообщает, выбран ли PostgreSQL (иначе — MSSQL по умолчанию). +func (s FancyMSSQLSettings) IsPostgres() bool { return s.DBType == "postgres" } + +// DefaultPort возвращает порт по типу СУБД, если он не задан явно. +func (s FancyMSSQLSettings) DefaultPort() int { + if s.Port != 0 { + return s.Port + } + if s.IsPostgres() { + return 5432 + } + return 1433 +} + // CryptoSettings — путь к PKCS#11 модулю и тип провайдера. type CryptoSettings struct { Provider string `json:"provider"` // "stub" | "validata" @@ -129,11 +220,44 @@ type NSDSettings struct { DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000 AccountID string `json:"account_id"` // депозитарный счёт SectionID string `json:"section_id"` // раздел депозитарного счёта + + // IGWv904 — ИШ обновлён до версии 107.0.0.904+ (Astra 107.0.907+): с этой + // версии в config.xml ОБЯЗАТЕЛЬНО указывать businessLine=MOST для M2M; на + // версиях младше — НЕ допускается. По умолчанию false (совместимо со старым + // ИШ — элемент не добавляется). + IGWv904 bool `json:"igw_v904"` } // LKSettings — настройки callback в ЛК клиента. type LKSettings struct { CallbackURL string `json:"callback_url"` + // PublicURL — адрес личного кабинета клиента (стенд/бой), куда оператор + // заходит подать тестовую заявку. Показывается ссылкой на дашборде. + PublicURL string `json:"lk_public_url"` + // APIKey — ключ доступа для модуля Личного кабинета клиента (bj-lk). + // Модуль ставится на веб-сервер клиента и обращается к bj-server по этому + // ключу (заголовок X-BJ-Key). Пустой ключ = модульный API выключен. + APIKey string `json:"lk_api_key"` + + // Интеграция с ЛК (потребление back_office API платформы): два окружения. + ActiveEnv string `json:"lk_active_env"` // "test" | "prod" + Test LKEndpoint `json:"lk_test"` + Prod LKEndpoint `json:"lk_prod"` + + // EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка, + // статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj. + // Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно. + EmulateNSD bool `json:"lk_emulate_nsd"` +} + +// LKEndpoint — реквизиты подключения к API ЛК (HTTP Basic) для одного окружения. +type LKEndpoint struct { + BaseURL string `json:"base_url"` // напр. https://fontvielle.ef.winr.pro/manager/api + Login string `json:"login"` // Basic-логин + Password string `json:"password"` // Basic-пароль + LastOK bool `json:"last_ok"` // результат последней «Проверить связь» + LastMsg string `json:"last_msg"` // текст результата + LastAt time.Time `json:"last_at"` // время последней проверки } // TestRunResult — результат последнего тестового прогона. @@ -181,6 +305,21 @@ func (r *RuntimeConfig) Snapshot() Settings { return out } +// BrandingSettings — оформление отчётов (логотип организации в шапке). +type BrandingSettings struct { + LogoDataURI string `json:"logo_data_uri,omitempty"` // data:image/...;base64,... (пусто = встроенный логотип по умолчанию) + LogoName string `json:"logo_name,omitempty"` // имя загруженного файла (для UI) +} + +// UpdateBranding сохраняет настройки оформления (логотип отчётов). +func (r *RuntimeConfig) UpdateBranding(s BrandingSettings) error { + r.mu.Lock() + r.data.Branding = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + // UpdatePostgres сохраняет postgres-настройки. func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error { r.mu.Lock() @@ -190,6 +329,24 @@ func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error { return r.save() } +// UpdateFancy сохраняет настройки подключения к MSSQL (канал обмена с Fancy). +func (r *RuntimeConfig) UpdateFancy(s FancyMSSQLSettings) error { + r.mu.Lock() + r.data.Fancy = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// UpdateQuik сохраняет настройки подключения к БД МНП QUIK. +func (r *RuntimeConfig) UpdateQuik(s QuikMNPSettings) error { + r.mu.Lock() + r.data.Quik = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + // SaveLicense сохраняет лицензионные настройки. func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error { r.mu.Lock() @@ -223,6 +380,7 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error { r.data.NSD = s r.data.UpdatedAt = time.Now().UTC() r.mu.Unlock() + applyBusinessLine(s.IGWv904) // сразу применяем businessLine к генератору config.xml return r.save() } @@ -285,6 +443,128 @@ func (r *RuntimeConfig) DismissNews(id string) error { return r.save() } +// AlertSettings — каналы внешней доставки системных оповещений (сверх ленты +// «Новости»). SMTP — почта оператору; Zabbix — trapper-хук в мониторинг. +type AlertSettings struct { + SMTP SMTPSettings `json:"smtp"` + Zabbix ZabbixSettings `json:"zabbix"` +} + +// SMTPSettings — параметры отправки уведомлений по e-mail. +type SMTPSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` // 587 (STARTTLS), 465 (SSL), 25 + Security string `json:"security"` // "starttls" | "ssl" | "none" + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + To string `json:"to"` // получатели через запятую + LastMsg string `json:"last_msg"` + LastAt time.Time `json:"last_at"` +} + +// ZabbixSettings — trapper-хук: bj шлёт значение элемента данных на сервер/прокси +// Zabbix (протокол zabbix_sender). В Zabbix заводится host + item типа +// «Zabbix trapper» с указанным ключом; триггер по значению шлёт уведомления. +type ZabbixSettings struct { + Enabled bool `json:"enabled"` + Server string `json:"server"` // адрес сервера/прокси Zabbix + Port int `json:"port"` // 10051 по умолчанию + Host string `json:"host"` // имя хоста в Zabbix (host.host) + Key string `json:"key"` // ключ элемента-trapper, напр. bj.alert + LastMsg string `json:"last_msg"` + LastAt time.Time `json:"last_at"` +} + +func (r *RuntimeConfig) UpdateAlerts(s AlertSettings) error { + r.mu.Lock() + r.data.Alerts = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + +// IncomingNotifySettings — уведомления депозитарию о входящих бумагах (перевод +// НАМ по M2M). Пока — письмо на почту «к вам переведена бумага, требует учёта»; +// позже здесь же сформируем пакет для загрузки в учётную систему. +type IncomingNotifySettings struct { + Enabled bool `json:"enabled"` + Emails string `json:"emails"` // депозитарий (через запятую) + EmailsBack string `json:"emails_back"` // бэк-офис + EmailsMiddle string `json:"emails_middle"` // мидл-офис + // Маршрутизация: тип уведомления → группы через запятую (depo,back,middle). + // Типы: incoming (входящие переводы), orders (сроки депо-поручений), quik (QUIK МНП), system (прочие). + Routes map[string]string `json:"routes"` +} + +// Группы получателей и типы уведомлений (порядок — для формы). +var notifyGroups = []struct{ Key, Title string }{{"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}} +var notifyKinds = []struct{ Key, Title string }{ + {"incoming", "Входящие переводы (получен / принят)"}, + {"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"}, + {"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"}, + {"system", "Прочие системные оповещения"}, +} + +// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои. +var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"} + +// RouteHas — получает ли группа уведомления данного типа. +func (s IncomingNotifySettings) RouteHas(kind, group string) bool { + r := s.Routes + if r == nil { + r = defaultNotifyRoutes + } + for _, g := range strings.Split(r[kind], ",") { + if strings.TrimSpace(g) == group { + return true + } + } + return false +} + +// GroupEmails — адреса группы. +func (s IncomingNotifySettings) GroupEmails(group string) string { + switch group { + case "depo": + return s.Emails + case "back": + return s.EmailsBack + case "middle": + return s.EmailsMiddle + } + return "" +} + +// Recipients — адреса всех групп, на которые направлен тип уведомления (без дублей). +func (s IncomingNotifySettings) Recipients(kind string) string { + seen := map[string]bool{} + var out []string + for _, g := range notifyGroups { + if !s.RouteHas(kind, g.Key) { + continue + } + for _, e := range strings.Split(s.GroupEmails(g.Key), ",") { + e = strings.TrimSpace(e) + if e == "" || seen[strings.ToLower(e)] { + continue + } + seen[strings.ToLower(e)] = true + out = append(out, e) + } + } + return strings.Join(out, ", ") +} + +func (r *RuntimeConfig) UpdateIncoming(s IncomingNotifySettings) error { + r.mu.Lock() + r.data.Incoming = s + r.data.UpdatedAt = time.Now().UTC() + r.mu.Unlock() + return r.save() +} + func (r *RuntimeConfig) UpdateLK(s LKSettings) error { r.mu.Lock() r.data.LK = s diff --git a/internal/lkgateway/russian.go b/internal/lkgateway/russian.go new file mode 100644 index 0000000..9068fa4 --- /dev/null +++ b/internal/lkgateway/russian.go @@ -0,0 +1,40 @@ +package lkgateway + +import "strings" + +// Через M2M (MOEX МОСТ) переводятся ТОЛЬКО российские ценные бумаги. +// Иностранные бумаги в системе не переводятся — их видно в портфеле, но +// заявку по ним подать нельзя. +// +// Признак страны берём из ISIN: первые два символа — код страны регистрации +// выпуска по ISO 3166. Российские выпуски начинаются с «RU». Отдельного поля +// резидентства в депозитарном справочнике OD_VALUES нет (в таблице всего 10 +// колонок), а балансовый счёт BAL_ACC для этого непригоден: на «российском» +// счёте 836 реально лежат и Meta (US30303M1027), и FinEx (IE00BD3QJ757). + +// isRussianISIN — российская ли бумага по коду ISIN. +func isRussianISIN(isin string) bool { + s := strings.ToUpper(strings.TrimSpace(isin)) + return strings.HasPrefix(s, "RU") +} + +// foreignNote — пояснение для клиента, почему бумагу нельзя перевести. +const foreignNote = "иностранная бумага — перевод через M2M недоступен" + +// markTransferable проставляет позициям признак «можно ли переводить». +// Иностранным бумагам обнуляет доступный остаток, чтобы они не попали +// ни в форму заявки, ни в расчёт лимитов. +func markTransferable(pos []ClientPosition) []ClientPosition { + for i := range pos { + if isRussianISIN(pos[i].ISIN) { + pos[i].Transferable = true + continue + } + pos[i].Transferable = false + pos[i].Available = 0 + if pos[i].Note == "" { + pos[i].Note = foreignNote + } + } + return pos +} diff --git a/internal/lkgateway/seedstore.go b/internal/lkgateway/seedstore.go index e750357..ad2ed3a 100644 --- a/internal/lkgateway/seedstore.go +++ b/internal/lkgateway/seedstore.go @@ -3,6 +3,8 @@ package lkgateway import ( "context" "fmt" + "strings" + "sync" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" @@ -13,6 +15,7 @@ import ( // docs/fansy-contract/v1/examples/seed-data.sql. Используется в // dev-стенде без реальной БД. type SeedStore struct { + mu sync.Mutex // защищает динамическую регистрацию клиентов ЛК clients map[string]*m2mcore.Client accounts map[string][]m2mcore.DepoAccount // by client_id } @@ -38,6 +41,16 @@ func NewSeedStore() *SeedStore { s.addClient("55555555-5555-5555-5555-555555555555", "Соколова", "Анна", "Викторовна", m2m.DocCode21, "4516", "654325") + // Тест-клиенты плана M2M (Приложение 2): К1 Мостовиков (с 16.09.2026, прогон с ГроттБьерн; + // до этого Бубликов 3453 289090), К2 Баранкин. + // ФИО и документ — как в тест-плане (m2mTestClients), чтобы EnrichRequest + // собирал СОГЛАСОВАННУЮ личность (раньше подставлялся Петров из 2222). + s.addClient(seedClientK1, + "Мостовиков", "Арсений", "Тимурович", + m2m.DocCode21, "4519", "736284") + s.addClient(seedClientK2, + "Баранкин", "Сергей", "Сергеевич", + m2m.DocCode21, "3763", "290089") s.addAccount("11111111-1111-1111-1111-111111111111", "DP789456", "31MC0021900000F01", "P001", "7702070139") @@ -53,10 +66,22 @@ func NewSeedStore() *SeedStore { "DP300400", "31MC0030000000B01", "B001", "0702345678") s.addAccount("55555555-5555-5555-5555-555555555555", "DP500600", "31MC0050000000C01", "C001", "0710987654") + // К1/К2 списывают с нашего реального тестового счёта в НРД (мы — Списывающий), + // то же место расчётов, что у тест-инвестора робота (иначе НРД: M2M19). + s.addAccount(seedClientK1, + "MC0413600000", "HL171004001C", "36MC0413600000F00", "7702165310") + s.addAccount(seedClientK2, + "MC0413600000", "HL171004001C", "36MC0413600000F00", "7702165310") return s } +// Идентификаторы сид-клиентов тест-плана M2M (реальный депозитарий, не робот). +const ( + seedClientK1 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // Мостовиков Арсений Тимурович (К1) + seedClientK2 = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" // Баранкин Сергей Сергеевич (К2) +) + // Clients возвращает копию слайса клиентов (для UI выбора). func (s *SeedStore) Clients() []*m2mcore.Client { out := make([]*m2mcore.Client, 0, len(s.clients)) @@ -90,8 +115,88 @@ func (s *SeedStore) addAccount(clientID, dep, acc, sec, depINN string) { }) } +// EnsureLKClient регистрирует (или обновляет) клиента Личного кабинета в +// справочнике: анкета берётся из учётной записи ЛК, депо-счета — из его +// портфеля (bj_client_positions). Нужно, чтобы EnrichRequest мог собрать +// M2MTransferRequest по заявке, поданной клиентом из кабинета. +func (s *SeedStore) EnsureLKClient(u LKUser, positions []ClientPosition, settle m2mcore.DepoAccount) { + s.mu.Lock() + defer s.mu.Unlock() + last, first, middle := u.LastName, u.FirstName, u.MiddleName + if last == "" && u.FullName != "" { + parts := strings.Fields(u.FullName) + if len(parts) > 0 { + last = parts[0] + } + if len(parts) > 1 { + first = parts[1] + } + if len(parts) > 2 { + middle = parts[2] + } + } + docType := u.DocType + if docType == "" { + docType = "21" // паспорт РФ + } + s.clients[u.Contract] = &m2mcore.Client{ + ID: u.Contract, + LastName: last, + FirstName: first, + MiddleName: middle, + Document: m2mcore.ClientDocument{ + DocumentType: m2m.IdentityDocumentCode(docType), + Series: u.DocSeries, + Number: u.DocNumber, + }, + } + // Депо-счета клиента — уникальные комбинации НРД-реквизитов места расчётов. + // У позиций из таблицы обмена реквизиты свои; у депозитарных позиций их нет + // (только внутренний счёт ВТНКЦ…) — тогда берём фиксированные реквизиты нашего + // депозитария в НРД (settle: deponent/account/section/ИНН из настроек). + seen := map[string]bool{} + var accs []m2mcore.DepoAccount + for _, p := range positions { + a := m2mcore.DepoAccount{ + ClientID: u.Contract, + DeponentCode: p.DeponentCode, + AccountID: m2m.AccountID(p.AccountID), + SectionID: p.SectionID, + DepositoryINN: m2m.OrganizationINN(p.SettlementIN), + } + if a.DeponentCode == "" { + a.DeponentCode = settle.DeponentCode + } + if a.AccountID == "" { + a.AccountID = settle.AccountID + } + if a.SectionID == "" { + a.SectionID = settle.SectionID + } + if a.DepositoryINN == "" { + a.DepositoryINN = settle.DepositoryINN + } + key := a.DeponentCode + "|" + string(a.AccountID) + "|" + a.SectionID + if a.AccountID == "" || seen[key] { + continue + } + seen[key] = true + accs = append(accs, a) + } + if len(accs) == 0 && settle.AccountID != "" { + // вообще нет позиций с реквизитами — один счёт по настройкам + settle.ClientID = u.Contract + accs = append(accs, settle) + } + if len(accs) > 0 { + s.accounts[u.Contract] = accs + } +} + // GetClientByID — реализация FansyStore. func (s *SeedStore) GetClientByID(_ context.Context, id string) (*m2mcore.Client, error) { + s.mu.Lock() + defer s.mu.Unlock() c, ok := s.clients[id] if !ok { return nil, fmt.Errorf("seedstore: клиент %s не найден", id) @@ -101,6 +206,8 @@ func (s *SeedStore) GetClientByID(_ context.Context, id string) (*m2mcore.Client // GetDepoAccounts — реализация FansyStore. func (s *SeedStore) GetDepoAccounts(_ context.Context, clientID string, _ m2m.OrganizationINN) ([]m2mcore.DepoAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() accs := s.accounts[clientID] if len(accs) == 0 { return nil, fmt.Errorf("seedstore: нет счетов у клиента %s", clientID) diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 4837564..c9408a0 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -3,8 +3,10 @@ package lkgateway import ( "context" "errors" + "fmt" "log" "net/http" + "os" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" @@ -12,6 +14,7 @@ import ( "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" ) // igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient: @@ -51,6 +54,10 @@ type Server struct { // (M2MTransferDecision/Response) и применяет через svc.ApplyDecision. igwClient *igw.Client igwChannel string + + // incoming — входящие переводы (M2MTransferRequest от робота/контрагента, + // сценарий 3333), на которые мы отвечаем M2MTransferDecision. + incoming *IncomingStore } // NewServer собирает Server с репозиторием, mock NSDSender, SeedStore @@ -81,6 +88,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { var igwChannel string { s := rc.Snapshot() + applyBusinessLine(s.NSD.IGWv904) // config.xml businessLine=MOST для ИШ 107.0.0.904+ if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" { prof, perr := nsdadapter.LookupProfile(s.NSD.Profile) if perr != nil { @@ -161,9 +169,32 @@ func NewServer(cfg ServerConfig) (*Server, error) { return nil, err } registerSetup(mux, adminTpl, rc, svc) + registerM2MTest(mux, adminTpl, &setupHandlers{rc: rc, tpl: &adminTemplates{a: adminTpl}, svc: svc}) + incomingStore := NewIncomingStore() + globalIncomingStore = incomingStore // для сводной выгрузки отчётов тест-плана + registerIncoming(mux, adminTpl, incomingDeps{ + store: incomingStore, + igwClient: igwClient, + igwChan: igwChannel, + rc: rc, + ourCode: string(cfg.DefaultSender), + }) + globalOutgoingStore = NewOutgoingStore() + registerDepoSim(mux, adminTpl) // песочница сверки с DEPO (имитация ответа Fancy) + registerOutgoing(mux, adminTpl, outgoingDeps{ + store: globalOutgoingStore, + igwClient: igwClient, + igwChan: igwChannel, + }) + registerJournal(mux, adminTpl, svc, incomingStore) + registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) + registerQuikOps(mux, rc, svc) + registerNPR(mux, adminTpl, rc) + registerAuth(mux, rc) registerSetCallback(mux, svc, rc) registerSeedListing(mux, store) + registerHandbookRequest(mux, igwClient, igwChannel) return &Server{ cfg: cfg, @@ -174,9 +205,10 @@ func NewServer(cfg ServerConfig) (*Server, error) { mux: mux, igwClient: igwClient, igwChannel: igwChannel, + incoming: incomingStore, server: &http.Server{ Addr: cfg.Addr, - Handler: mux, + Handler: authMiddleware(rc, mux), ReadHeaderTimeout: 5 * time.Second, }, }, nil @@ -210,21 +242,80 @@ func (s *Server) Mux() http.Handler { return s.mux } // Run поднимает HTTP-сервер и фоновый Decisions-consumer. // Блокируется до ctx.Done(). func (s *Server) Run(ctx context.Context) error { - go s.consumeDecisions(ctx) + // Монитор здоровья + systemd watchdog: следит за фоновыми горутинами, + // поднимает алерт при зависании и «пинает» systemd, пока всё живо. + healthRegister("poll-lk", time.Minute) + go s.superviseHealth(ctx) + + // Данные модуля личного кабинета — схема lk в PostgreSQL bj. + if err := ensureLKSchema(ctx, s.rc); err != nil { + log.Printf("lk-gateway: схема lk в PostgreSQL: %v", err) + } + + // Все фоновые горутины — под supervise: паника ловится recover(), пишется + // в лог и в ленту, горутина перезапускается. Один сбой не роняет сервис. + go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions) // Поллер входящих от НРД (только в реальном режиме ИШ): забирает // ответы робота/контрагента и применяет их через ApplyDecision. if s.igwClient != nil && s.igwChannel != "" { - go s.pollIncoming(ctx) + healthRegister("poll-incoming", 30*time.Second) + go superviseLoop(ctx, "poll-incoming", s.rc, s.pollIncoming) } + // Имитатор НРД для заявок ЛК в тестовом режиме (lkemulate.go): решения контрагента + // применяются тем же обработчиком, что настоящие ответы из ИШ. + lkEmuSender = newLKEmuSender() + go superviseLoop(ctx, "lk-emulator", s.rc, s.consumeEmulatedDecisions) + // Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК. + go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop) + + // Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и + // отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано. + go superviseLoop(ctx, "poll-lk", s.rc, s.pollLKClaims) + + // Авто-обновление справочника участников M2M раз в сутки (только реальный + // ИШ). Тик суточный — в heartbeat-монитор не регистрируем, только recover. + go superviseLoop(ctx, "handbook-refresh", s.rc, s.handbookRefreshLoop) + + // QUIK МНП: автоматический цикл M2M (регистрация до отправки — хук в + // SendBuiltRequest; подпись/снятие/зачисление по MS010 — опросчик). + quikSendHook = func(ctx context.Context, req *m2m.M2MTransferRequest) error { + return quikOutgoingBeforeSend(ctx, s.rc, req) + } + healthRegister("poll-quik", 30*time.Second) + go superviseLoop(ctx, "poll-quik", s.rc, s.quikCycleLoop) + + // Контроль сроков депо-поручений 16/16-1: MS010 за 5 мин, исполнение до 30 дней. + healthRegister("order-watch", time.Minute) + go superviseLoop(ctx, "order-watch", s.rc, s.orderWatchLoop) + + // DEPO (bj2depo): события BJ_* в трансферные таблицы, чтение DEPO_*, справочник участников. + sentHook = func(req *m2m.M2MTransferRequest) { depoOnOutgoingSent(s.rc, req) } + healthRegister("depo-events", time.Minute) + go superviseLoop(ctx, "depo-events", s.rc, s.depoEventsLoop) + healthRegister("depo-job", 2*time.Minute) + go superviseLoop(ctx, "depo-job", s.rc, s.depoJobLoop) + + // Перепроверка групп AD раз в 15 мин (если задана сервисная учётная запись). + // Тик длинный, контроллер может отвечать медленно — только recover, без heartbeat. + go superviseLoop(ctx, "ad-recheck", s.rc, s.adRecheckLoop) + // Фоновая авто-проверка обновлений из артефактории (если включена). go NewUpdater(s.rc).updateLoop(ctx) + // Лицензия: ежесуточное продление ключа на сервере лицензий lic.zetit.ru. + go superviseLoop(ctx, "license", s.rc, s.licenseLoop) + // Авто-обновление сертификатов УЦ раз в сутки (если оператор включил). stopCACerts := StartCACertsAutoUpdater(s.rc) defer stopCACerts() + // Доверенные сертификаты НРД: вшитый комплект → справочник активного + // профиля ИШ (добавление/плановое удаление, с бэкапом и откатом). + stopNSDCerts := StartNSDCertsSync(s.rc) + defer stopNSDCerts() + // Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и // эмитирует новости в ленту. Дефолтные источники + дефолтные // новости (окно техработ TEST3, появление робота) сеются один раз. @@ -278,6 +369,16 @@ func (s *Server) consumeDecisions(ctx context.Context) { } } +// applyBusinessLine синхронизирует флаг «ИШ 107.0.0.904+» с генератором +// config.xml: v904 → businessLine=MOST, иначе элемент опускается. +func applyBusinessLine(v904 bool) { + if v904 { + igw.BusinessLine = "MOST" + } else { + igw.BusinessLine = "" + } +} + func registerHealth(mux *http.ServeMux) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) @@ -285,6 +386,74 @@ func registerHealth(mux *http.ServeMux) { }) } +// sendHandbookRequest собирает и отправляет в ИШ запрос справочника (#M2MHR). +func sendHandbookRequest(ctx context.Context, igwClient *igw.Client, channel string) (string, error) { + if igwClient == nil || channel == "" { + return "", fmt.Errorf("ИШ не настроен (реальный режим НРД выключен)") + } + xmlBytes, err := nsdxml.Marshal(m2m.M2MTransferHandbookRequest{}) + if err != nil { + return "", fmt.Errorf("сборка XML: %w", err) + } + zip, err := igw.PackXML(xmlBytes, "M2MTransferHandbookRequest.xml", "#M2MHR") + if err != nil { + return "", fmt.Errorf("упаковка: %w", err) + } + return igwClient.SendPackage(ctx, channel, "#M2MHR", zip) +} + +// registerHandbookRequest — POST /admin/setup/handbook-request: ручной запрос +// актуального справочника участников M2M. Ответ #M2MTH разберёт поллер. +func registerHandbookRequest(mux *http.ServeMux, igwClient *igw.Client, channel string) { + mux.HandleFunc("/admin/setup/handbook-request", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + id, err := sendHandbookRequest(ctx, igwClient, channel) + if err != nil { + setupFlash(w, r, "Запрос справочника: "+err.Error()) + return + } + setupFlash(w, r, "Запрос справочника участников M2M отправлен (#M2MHR, пакет id="+id+"). Ответ придёт во входящие через ~30-60с — список появится в логе и в файле m2m-handbook.json.") + }) +} + +// handbookRefreshLoop раз в сутки обновляет справочник участников M2M, чтобы он +// оставался актуальным. Первый запрос — вскоре после старта, если справочника +// ещё нет или он старше суток. Работает только в реальном режиме ИШ. +func (s *Server) handbookRefreshLoop(ctx context.Context) { + if s.igwClient == nil || s.igwChannel == "" { + <-ctx.Done() // нечего делать в mock-режиме, но не выходим (иначе supervise перезапустит) + return + } + const interval = 24 * time.Hour + initial := time.Minute + if fi, err := os.Stat(handbookFile); err == nil && time.Since(fi.ModTime()) < interval { + initial = interval - time.Since(fi.ModTime()) // справочник свежий — ждём до суток + } + timer := time.NewTimer(initial) + defer timer.Stop() + log.Printf("lk-gateway: авто-обновление справочника M2M включено (интервал %s, первый запрос через %s)", interval, initial.Round(time.Second)) + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + if id, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil { + log.Printf("lk-gateway: авто-обновление справочника: %v", err) + } else { + log.Printf("lk-gateway: авто-обновление справочника: запрос отправлен (#M2MHR id=%s)", id) + } + cancel() + timer.Reset(interval) + } + } +} + // registerSetCallback — служебный POST /admin/api/callback-url для // эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён // в runtime-конфиге (пользователь явно настроил его через UI), запрос diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go index f8f03e9..4d3c2c7 100644 --- a/internal/lkgateway/service.go +++ b/internal/lkgateway/service.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "sort" + "strings" "sync" "time" @@ -62,12 +63,29 @@ func NewService(cfg Config) *Service { // CreateClaim принимает DTO заявки, формирует M2MTransferRequest, // создаёт сделку и отправляет в НРД. +// quikSendHook — вызывается перед отправкой M2MTR в НРД (регистрация «Вывод ЦБ (M2M)» в QUIK МНП). +// Ошибка хука отменяет отправку. Назначается в server.go. +var quikSendHook func(ctx context.Context, req *m2m.M2MTransferRequest) error + +// sentHook — вызывается после успешной отправки M2MTR (запись в трансферные таблицы DEPO). +var sentHook func(req *m2m.M2MTransferRequest) + func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) { + req, err := s.BuildRequest(ctx, in) + if err != nil { + return CreateClaimResponse{}, err + } + return s.SendBuiltRequest(ctx, req, in.Investor.ID, []byte(in.SignedDocument)) +} + +// BuildRequest формирует обогащённый M2MTransferRequest из DTO (GUID, +// ReferenceID, реквизиты) БЕЗ отправки и без создания заявки. Нужно, чтобы +// сначала положить запрос в БД Fancy на проверку, а отправить позже. +func (s *Service) BuildRequest(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) { domainClaim, err := dtoToClaim(in) if err != nil { - return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err) + return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err) } - receiver := s.defaultReceiver if in.ReceiverCodeOverride != "" { receiver = m2m.DeponentCode(in.ReceiverCodeOverride) @@ -77,10 +95,43 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat ReceiverCode: receiver, }) if err != nil { - return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err) + return nil, fmt.Errorf("lkgateway: EnrichRequest: %w", err) } + return req, nil +} - deal, err := m2mcore.NewDeal(req.Header.GUID, in.Investor.ID, []byte(in.SignedDocument)) +// BuildRequestUnchecked — BuildRequest без финальной проверки формата НРД: пакет нужен +// для журнала и отказа через Fancy даже при некорректных данных заявки (lkemulate.go). +func (s *Service) BuildRequestUnchecked(ctx context.Context, in CreateClaimRequest) (*m2m.M2MTransferRequest, error) { + domainClaim, err := dtoToClaim(in) + if err != nil { + return nil, fmt.Errorf("lkgateway: dtoToClaim: %w", err) + } + receiver := s.defaultReceiver + if in.ReceiverCodeOverride != "" { + receiver = m2m.DeponentCode(in.ReceiverCodeOverride) + } + req, err := m2mcore.EnrichRequestUnchecked(ctx, s.store, domainClaim, m2mcore.SenderReceiver{ + SenderCode: s.defaultSender, + ReceiverCode: receiver, + }) + if err != nil { + return nil, fmt.Errorf("lkgateway: EnrichRequest: %w", err) + } + return req, nil +} + +// SendBuiltRequest создаёт заявку (Deal) по готовому запросу, отправляет его в +// НРД и сохраняет ответ. Используется и обычным CreateClaim, и поллером Fancy +// (когда «кран» одобрил запрос в БД). +func (s *Service) SendBuiltRequest(ctx context.Context, req *m2m.M2MTransferRequest, investorID string, signedDoc []byte) (CreateClaimResponse, error) { + return s.SendBuiltRequestVia(ctx, s.sender, req, investorID, signedDoc) +} + +// SendBuiltRequestVia — SendBuiltRequest через указанный отправитель (имитатор НРД +// для заявок ЛК в тестовом режиме, lkemulate.go). Хуки QUIK и DEPO срабатывают так же. +func (s *Service) SendBuiltRequestVia(ctx context.Context, sender m2mcore.NSDSender, req *m2m.M2MTransferRequest, investorID string, signedDoc []byte) (CreateClaimResponse, error) { + deal, err := m2mcore.NewDeal(req.Header.GUID, investorID, signedDoc) if err != nil { return CreateClaimResponse{}, fmt.Errorf("lkgateway: NewDeal: %w", err) } @@ -88,17 +139,20 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat if err != nil { return CreateClaimResponse{}, fmt.Errorf("lkgateway: repo.Create: %w", err) } - if err := saved.Validate(ctx, req); err != nil { return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Validate: %w", err) } - - resp, err := s.sender.Send(ctx, req) + // QUIK МНП: блокировка бумаг клиента до отправки в НРД (если цикл включён). + if quikSendHook != nil { + if err := quikSendHook(ctx, req); err != nil { + return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err) + } + } + resp, err := sender.Send(ctx, req) if err != nil { return CreateClaimResponse{}, fmt.Errorf("lkgateway: sender.Send: %w", err) } saved.Response = resp - if err := saved.Submit(ctx); err != nil { return CreateClaimResponse{}, fmt.Errorf("lkgateway: deal.Submit: %w", err) } @@ -109,7 +163,9 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat s.mu.Lock() s.claimToID[saved.ID] = saved.ID s.mu.Unlock() - + if sentHook != nil { + go sentHook(req) + } return CreateClaimResponse{ ID: saved.ID, Status: string(saved.State), @@ -127,6 +183,35 @@ func (s *Service) GetClaim(ctx context.Context, id string) (ClaimView, error) { return dealToView(deal), nil } +// ClaimRawXML возвращает ТОЧНЫЕ байты (windows-1251) отправленного запроса и +// ответа НРД по заявке — для скачивания и пересылки в поддержку M2MOST@nsd.ru. +// kind: "request" (M2MTransferRequest) или "response" (M2MTransferResponse). +func (s *Service) ClaimRawXML(ctx context.Context, id, kind string) ([]byte, error) { + deal, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + switch kind { + case "request": + if deal.Request == nil { + return nil, errors.New("lkgateway: у заявки нет отправленного запроса") + } + return nsdxml.Marshal(deal.Request) + case "response": + if len(deal.RawResponse) == 0 { + return nil, errors.New("lkgateway: по заявке ещё нет ответа НРД") + } + return deal.RawResponse, nil + case "decision": + if deal.Decision == nil { + return nil, errors.New("lkgateway: по заявке нет решения НРД (Decision)") + } + return nsdxml.Marshal(deal.Decision) + default: + return nil, fmt.Errorf("lkgateway: неизвестный тип XML %q", kind) + } +} + // ListClaims возвращает страницу заявок. func (s *Service) ListClaims(ctx context.Context, filter m2mcore.Filter) (ClaimsPage, error) { if filter.Limit == 0 { @@ -355,16 +440,23 @@ func dealToView(d *m2mcore.Deal) ClaimView { UpdatedAt: d.UpdatedAt, M2MGUID: d.GUID, } + // Идентификатор инвестора (UUID) — для выгрузки всех заявок клиента. + out.Investor.ID = d.InvestorID for _, st := range d.Stages { out.Stages = append(out.Stages, StageView{ State: string(st.State), EnteredAt: st.EnteredAt, LeftAt: st.LeftAt, Reason: st.Reason, }) } if d.Request != nil { + // Точный отправленный пакет (windows-1251 → UTF-8) для просмотра в карточке. + if b, err := nsdxml.Marshal(d.Request); err == nil { + out.RequestRawXML = string(nsdxml.DecodeWindows1251(b)) + } out.TransferringDepositoryINN = string(d.Request.Data.TransferringDepository.INN) out.ReceivingDepositoryINN = string(d.Request.Data.ReceivingDepository.INN) ii := d.Request.Data.InvestorInformation out.Investor = Investor{ + ID: d.InvestorID, LastName: ii.LastName, FirstName: ii.FirstName, MiddleName: ii.MiddleName, Document: Document{ DocumentType: string(ii.IdentityDocument.DocumentType), @@ -388,6 +480,32 @@ func dealToView(d *m2mcore.Deal) ClaimView { BrokerINN: string(d.Request.Header.IIAAgreementDetails.BrokerINN), } } + // Ценные бумаги из отправленного запроса — для блока «ЦБ» в карточке + // и колонки ЦБ в журнале (раньше не заполнялось → показывало 0). + for _, sec := range d.Request.Data.TransferredSecurities.Securities { + cs := ClaimSec{SecurityCode: string(sec.SecurityCode)} + if sec.SecurityDetails.ISIN != nil { + cs.SecurityDetails.ISIN = string(*sec.SecurityDetails.ISIN) + } + // Количество: целое (Whole) или дробное (Fractional, у паёв). Раньше + // читали только Whole → паи показывались пустыми/«0». + if sec.Quantity.Whole != nil { + cs.Quantity.Whole = *sec.Quantity.Whole + } else if sec.Quantity.Fractional != nil { + cs.Quantity.Fractional = string(*sec.Quantity.Fractional) + } + for _, sa := range sec.SettlementAccount { + cs.SettlementAccounts = append(cs.SettlementAccounts, SettlementAccount{ + SettlementRequisitesINN: string(sa.SettlementRequisites.INN), + SettlementLocation: SettlementLocation{ + DeponentCode: sa.SettlementLocation.DeponentCode, + AccountID: string(sa.SettlementLocation.AccountID), + SectionID: sa.SettlementLocation.SectionID, + }, + }) + } + out.Securities = append(out.Securities, cs) + } } if d.Response != nil { out.M2MResponse = responseToView(d.Response) @@ -403,9 +521,87 @@ func dealToView(d *m2mcore.Deal) ClaimView { cb := callbackForDeal(d) out.LastCallback = &cb } + // Самопроверка тестовой заявки роботу: серия документа = код сценария. + out.TestResult = buildTestResult(out.Investor.Document.Series, out.M2MDecision, out.M2MResponse) return out } +// buildTestResult формирует «ожидали/получили/вердикт» для тестовой заявки +// роботу НРД. Сценарий определяется по серии документа инвестора (= код +// сценария 1111/2001/2002/3333). Для нетестовых заявок возвращает nil. +func buildTestResult(series string, dec *NSDDecisionView, resp *NSDResponseView) *TestResultView { + expected := map[string][2]string{ + "1111": {"Ответ с отказом", "M2MTransferDecision: все бумаги отклонены (Rejection) с кодом ошибки из номера документа"}, + "2001": {"Принять все бумаги", "M2MTransferDecision: все бумаги приняты (Confirmation)"}, + "2002": {"Принять бумаги частично", "M2MTransferDecision: часть бумаг принята, часть отклонена (M2M05)"}, + "3333": {"Робот — принимающая сторона", "M2MTransferDecision: отказ M2M05 + отдельное сообщение — новый перевод от робота"}, + } + exp, ok := expected[series] + if !ok { + return nil // не тестовая заявка + } + tr := &TestResultView{Scenario: series, Title: exp[0], Expected: exp[1]} + + // Сначала смотрим решение (Decision) — целевой ответ для всех сценариев. + if dec != nil && len(dec.Securities) > 0 { + conf, rej := 0, 0 + codes := map[string]bool{} + for _, s := range dec.Securities { + if s.Outcome == "confirmed" { + conf++ + } else { + rej++ + } + for _, c := range s.RejectCodes { + codes[c] = true + } + } + codeList := make([]string, 0, len(codes)) + for c := range codes { + codeList = append(codeList, c) + } + sort.Strings(codeList) + tr.Got = fmt.Sprintf("M2MTransferDecision: принято %d, отклонено %d", conf, rej) + if len(codeList) > 0 { + tr.Got += " (коды отказа: " + strings.Join(codeList, ", ") + ")" + } + // Оценка по сценарию. + switch series { + case "2001": + tr.statusFromBool(conf > 0 && rej == 0, "все бумаги приняты — как и ожидалось", "ожидался приём всех бумаг") + case "1111": + tr.statusFromBool(rej > 0 && conf == 0, "все бумаги отклонены — как и ожидалось", "ожидался отказ по всем бумагам") + case "2002": + tr.statusFromBool(conf > 0 && rej > 0, "часть принята, часть отклонена — как и ожидалось", "ожидался частичный приём") + case "3333": + tr.statusFromBool(rej > 0, "робот отклонил перевод (M2M05) и должен прислать свой перевод отдельным сообщением", "ожидался отказ M2M05") + } + return tr + } + + // Решения нет — но мог прийти ответ сервиса МОСТ (ошибка формата/бумаг). + if resp != nil { + var codes []string + for _, e := range resp.Responses { + if e.Code != "" { + codes = append(codes, e.Code) + } + } + tr.Got = "Ответ сервиса МОСТ: " + resp.StatusCode + if len(codes) > 0 { + tr.Got += " (" + strings.Join(codes, ", ") + ")" + } + tr.Status = "error" + tr.Note = "решение не получено — сервис НРД вернул ошибку до приёмки (см. коды)" + return tr + } + + tr.Got = "ответ робота ещё не получен" + tr.Status = "waiting" + tr.Note = "ждём ответ робота (обычно 30–60 сек)" + return tr +} + func responseToView(r *m2m.M2MTransferResponse) *NSDResponseView { v := &NSDResponseView{ GUID: string(r.GUID), diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 11c8083..8de87a7 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -3,6 +3,7 @@ package lkgateway import ( "context" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -67,6 +68,8 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service }) mux.HandleFunc("/admin/setup/postgres", h.savePostgres) mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres) + h.registerFancy(mux) // вкладка «Обмен Fancy (MSSQL)» + h.registerQuik(mux) // вкладка «QUIK МНП» (блокировка бумаг) mux.HandleFunc("/admin/setup/crypto", h.saveCrypto) mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto) mux.HandleFunc("/admin/setup/crypto/test-sign", h.testSignCrypto) @@ -75,22 +78,38 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/update/check", h.checkUpdate) mux.HandleFunc("/admin/setup/update/apply", h.applyUpdate) mux.HandleFunc("/admin/setup/license", h.saveLicense) + mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate) + mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow) + mux.HandleFunc("/admin/setup/logo", h.saveLogo) + mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo) mux.HandleFunc("/admin/setup/nsd", h.saveNSD) mux.HandleFunc("/admin/setup/lk", h.saveLK) + mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) + mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) + mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) + mux.HandleFunc("/admin/setup/lk-pull", h.lkPull) + mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP) + mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP) + mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix) + mux.HandleFunc("/admin/setup/alerts-zabbix-test", h.testAlertsZabbix) + mux.HandleFunc("/admin/setup/incoming-notify", h.saveIncomingNotify) + mux.HandleFunc("/admin/setup/incoming-notify-test", h.testIncomingNotify) + mux.HandleFunc("/admin/setup/advanced-unlock", h.unlockAdvanced) + mux.HandleFunc("/admin/setup/advanced-lock", h.lockAdvanced) mux.HandleFunc("/admin/setup/test-run", h.testRun) // Авто-загрузка сертификатов УЦ НРД и нашего УЦ. mux.HandleFunc("/admin/setup/cacerts", h.saveCACerts) mux.HandleFunc("/admin/setup/cacerts/fetch", h.fetchCACertsNow) - // Носители ключей (USB-флешки, загруженные ISO). - mux.HandleFunc("/admin/setup/media/iso/upload", h.uploadISO) - mux.HandleFunc("/admin/setup/media/iso/unmount", h.unmountISO) - mux.HandleFunc("/admin/setup/media/import-container", h.importContainer) - mux.HandleFunc("/admin/setup/media/import-profile", h.importProfile) - mux.HandleFunc("/admin/setup/media/activate-profile", h.activateProfile) - mux.HandleFunc("/admin/setup/media/delete-profile", h.deleteProfile) - mux.HandleFunc("/admin/setup/media/delete-container", h.deleteContainer) + // Доверенные сертификаты НРД в справочнике ИШ (вшитый комплект + ручные). + registerQuikExportSetup(mux, h) + registerUsersSetup(mux, h) + mux.HandleFunc("/admin/setup/npr", h.saveNPR) + mux.HandleFunc("/admin/setup/npr/test", h.testNPR) + mux.HandleFunc("/admin/setup/nsdcerts", h.saveNSDCerts) + mux.HandleFunc("/admin/setup/nsdcerts/check", h.checkNSDCerts) + mux.HandleFunc("/admin/setup/nsdcerts/apply", h.applyNSDCerts) // Перезапуск сервисов из UI. mux.HandleFunc("/admin/setup/restart-crypto", h.restartCrypto) @@ -267,21 +286,50 @@ func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) { // SetupData — данные для шаблона admin_setup.html. type SetupData struct { page - Settings Settings - Readiness []Readiness - ReadyCount int - TotalCount int - Certificates []cryptocli.Certificate - Media []Medium // USB-флешки и распакованные ISO - ImportedContainers []KeyContainer // уже импортированные контейнеры - ImportedProfiles []string // уже импортированные профили Валидаты - CurrentVersion string // версия bj-server (для раздела «Обновления») - License LicenseStatus // статус лицензии - Flash string - Error string + Settings Settings + Readiness []Readiness + ReadyCount int + TotalCount int + Certificates []cryptocli.Certificate + CurrentVersion string // версия bj-server (для раздела «Обновления») + License LicenseStatus // статус лицензии + Health []HealthStatus // здоровье фоновых компонентов + AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy) + NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ + NotifyGroups []struct{ Key, Title string } + NotifyKinds []struct{ Key, Title string } + DepoJob DepoJobView + DepoTables DepoTablesView // трансферные таблицы FTV_M2M* в базе DEPO + QuikExport QuikExportView // модуль «Экспорт биржевой информации» + Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак) + Features []LicenseFeatureView // признаки лицензии для карточки + Users UsersView // пользователи, роли, сессии + Flash string + Error string } -func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) { +// HealthLabel — человекочитаемое имя компонента для UI. +func HealthLabel(name string) string { + switch name { + case "poll-lk": + return "Опрос заявок ЛК ЕСИА Финанс" + case "poll-incoming": + return "Опрос ответов НРД (ИШ)" + case "consume-decisions": + return "Приём решений" + case "poll-quik": + return "Цикл QUIK МНП (подпись / снятие / зачисление)" + case "order-watch": + return "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)" + case "depo-events": + return "Обмен с DEPO (bj2depo): события и справочник" + case "depo-job": + return "Обработка трансферных таблиц DEPO силами bj" + } + return name +} + +func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, flash string) { s := h.rc.Snapshot() r := h.rc.ReadinessSummary() ready := 0 @@ -291,18 +339,26 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flas } } data := SetupData{ - page: nowPage("Настройка", "setup"), - Settings: s, - Readiness: r, - ReadyCount: ready, - TotalCount: len(r), - Certificates: h.listCertsForUI(), - Media: ScanMedia(), - ImportedContainers: ListImportedContainers(), - ImportedProfiles: ListImportedProfiles(), - CurrentVersion: BuildVersion, - License: licenseStatus(h.rc), - Flash: flash, + page: nowPage("Настройка", "setup"), + Settings: s, + Readiness: r, + ReadyCount: ready, + TotalCount: len(r), + Certificates: h.listCertsForUI(), + CurrentVersion: BuildVersion, + License: licenseStatus(h.rc), + Health: HealthSnapshot(), + AdvancedUnlocked: advancedUnlocked(req), + NSDCerts: nsdCertsView(s), + NotifyGroups: notifyGroups, + NotifyKinds: notifyKinds, + DepoJob: depoJobView(s.Fancy), + DepoTables: depoTablesView(s.Fancy), + QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"), + Modules: modulesAllowed(h.rc), + Features: licenseFeatureViews(h.rc), + Users: usersView(w), + Flash: flash, } if errVal := errMsgFromQuery(_q(w)); errVal != "" { data.Error = errVal @@ -454,6 +510,7 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) { DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")), AccountID: strings.TrimSpace(r.FormValue("account_id")), SectionID: strings.TrimSpace(r.FormValue("section_id")), + IGWv904: r.FormValue("igw_v904") == "on", } if s.IGWBaseURL != "" { if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil { @@ -478,7 +535,16 @@ func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) { http.Error(w, "method", http.StatusMethodNotAllowed) return } - s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))} + // Стартуем от текущих настроек, чтобы не затереть APIKey/PublicURL. + s := h.rc.Snapshot().LK + s.CallbackURL = strings.TrimSpace(r.FormValue("callback_url")) + s.PublicURL = strings.TrimSpace(r.FormValue("lk_public_url")) + if s.PublicURL != "" { + if _, err := url.ParseRequestURI(s.PublicURL); err != nil { + setupFlash(w, r, "lk: невалидный адрес ЛК: "+err.Error()) + return + } + } if s.CallbackURL != "" { if _, err := url.ParseRequestURI(s.CallbackURL); err != nil { setupFlash(w, r, "lk: невалидный URL: "+err.Error()) @@ -499,6 +565,380 @@ func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "Callback в ЛК сохранён и применён") } +// lkEnvPtr возвращает окружение ЛК по имени (test/prod) внутри LKSettings. +func lkEnvPtr(s *LKSettings, env string) *LKEndpoint { + if env == "prod" { + return &s.Prod + } + return &s.Test +} + +// saveLKIntegration — сохранение реквизитов подключения к API ЛК (тест/бой). +func (h *setupHandlers) saveLKIntegration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + env := r.FormValue("env") + if env != "test" && env != "prod" { + setupFlash(w, r, "lk: неизвестное окружение") + return + } + s := h.rc.Snapshot().LK + ep := lkEnvPtr(&s, env) + ep.BaseURL = strings.TrimRight(strings.TrimSpace(r.FormValue("base_url")), "/") + ep.Login = strings.TrimSpace(r.FormValue("login")) + if pw := r.FormValue("password"); pw != "" { + ep.Password = pw // пустое поле не затирает сохранённый пароль + } + if ep.BaseURL != "" { + if _, err := url.ParseRequestURI(ep.BaseURL); err != nil { + setupFlash(w, r, "lk: невалидный адрес API: "+err.Error()) + return + } + } + if a := r.FormValue("active_env"); a == "test" || a == "prod" { + s.ActiveEnv = a + } + if r.FormValue("emulate_nsd_form") != "" { // флажок есть только в форме активного окружения + s.EmulateNSD = r.FormValue("emulate_nsd") != "" + } + if err := h.rc.UpdateLK(s); err != nil { + setupFlash(w, r, "lk: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Интеграция с ЛК ("+lkEnvName(env)+") сохранена") +} + +// checkLKConn — «Проверить связь»: GET к BaseURL с Basic-авторизацией. +// Различает: связи нет / авторизация не прошла / связь есть. +func (h *setupHandlers) checkLKConn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + env := r.FormValue("env") + s := h.rc.Snapshot().LK + ep := lkEnvPtr(&s, env) + if ep.BaseURL == "" { + setupFlash(w, r, "lk: сначала укажите адрес API "+lkEnvName(env)) + return + } + ok, msg := probeLK(*ep) + ep.LastOK, ep.LastMsg, ep.LastAt = ok, msg, time.Now() + _ = h.rc.UpdateLK(s) + setupFlash(w, r, "Проверка связи с ЛК ("+lkEnvName(env)+"): "+msg) +} + +// lkAPIBase возвращает базу back_office API ЛК ЕСИА Финанс из адреса стенда. +// Пользователь вводит корень (https://host), API живёт на /api/v1/back_office. +func lkAPIBase(baseURL string) string { + b := strings.TrimRight(baseURL, "/") + if strings.Contains(b, "/api/v1/back_office") { + return b + } + return b + "/api/v1/back_office" +} + +// probeLK проверяет связь по реальному эндпоинту API с Basic-авторизацией — +// так 401 честно отличается от «связь есть» (а не корневой SPA, отдающий 200). +func probeLK(ep LKEndpoint) (bool, string) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + u := lkAPIBase(ep.BaseURL) + "/notifications/flat?per_page=1" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return false, "неверный адрес: " + err.Error() + } + req.Header.Set("Accept", "application/json") + if ep.Login != "" { + req.SetBasicAuth(ep.Login, ep.Password) + } + resp, err := (&http.Client{Timeout: 8 * time.Second}).Do(req) + if err != nil { + return false, "связи нет — сервер недоступен (" + err.Error() + ")" + } + defer resp.Body.Close() + switch { + case resp.StatusCode == 401 || resp.StatusCode == 403: + return false, fmt.Sprintf("сервер отвечает, но авторизация не прошла (HTTP %d) — проверьте логин/пароль", resp.StatusCode) + case resp.StatusCode >= 500: + return false, fmt.Sprintf("сервер вернул ошибку (HTTP %d)", resp.StatusCode) + default: + return true, fmt.Sprintf("связь есть (HTTP %d)", resp.StatusCode) + } +} + +func lkEnvName(env string) string { + if env == "prod" { + return "бой" + } + return "тест" +} + +// lkSimulate — симулятор подачи заявки: bj собирает depo_m2m и POST-ит в API ЛК +// ЕСИА Финанс (пока подача через веб-форму у них не готова). Показывает сырой +// ответ, чтобы докрутить контракт и затем «поймать» заявку опросом. +func (h *setupHandlers) lkSimulate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := h.rc.Snapshot().LK + ep := s.Test + if s.ActiveEnv == "prod" { + ep = s.Prod + } + if ep.BaseURL == "" || ep.Login == "" { + setupFlash(w, r, "Симулятор: сначала заполните и проверьте подключение к API ЛК") + return + } + // Реальная структура стенда (проверено): kind=depo_m2m, поля бумаги ПЛОСКО в data. + body := map[string]any{"claim": map[string]any{ + "kind": "depo_m2m", + "user_id": atoiDefault(r.FormValue("user_id"), 0), + "owner_type": "Contract", + "owner_id": atoiDefault(r.FormValue("contract_id"), 0), + // Структура по swagger (test/prod): paper{} вложен (name/isin/type/reg_number), + // paper_count — ПЛОСКО в data (float), review_links — массив {text,url}. + "data": map[string]any{ + "account": r.FormValue("account"), + "section": r.FormValue("section"), + "paper": map[string]any{ + "name": r.FormValue("paper_name"), + "isin": r.FormValue("paper_isin"), + "type": nonEmpty(r.FormValue("paper_type"), "share"), + "registration_number": r.FormValue("paper_reg"), + }, + "paper_count": atofDefault(r.FormValue("paper_count"), 1), + "contragent_inn": r.FormValue("contragent_inn"), + "contragent_name": r.FormValue("contragent_name"), + "contragent_place_of_payment": nonEmpty(r.FormValue("place_of_payment"), "nrd"), + "expense_information": r.FormValue("expense_information") == "on", + "review_links": []any{}, + }, + }} + raw, _ := json.Marshal(body) + code, respBody := lkPostJSON(lkAPIBase(ep.BaseURL)+"/claims", ep, raw) + snip := respBody + if len(snip) > 400 { + snip = snip[:400] + "…" + } + setupFlash(w, r, fmt.Sprintf("Симулятор → POST /claims: HTTP %d · %s", code, snip)) +} + +// lkPull — «Забрать заявки из ЛК»: один проход коннектора (GET in_queue → +// сборка M2M → отправка в НРД → PATCH «В работе»). Ручной триггер; штатно +// это делает фоновый поллер, но кнопка нужна для первичного теста. +func (h *setupHandlers) lkPull(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + res, err := PullLKClaims(ctx, h.rc, h.svc) + if err != nil { + log.Printf("lk-gateway: ручной забор ЛК: %v", err) + setupFlash(w, r, "Забор заявок из ЛК: "+err.Error()) + return + } + msg := fmt.Sprintf("Забор заявок из ЛК: получено %d, отправлено в НРД %d, отклонено %d", res.Fetched, res.Sent, res.Rejected) + if res.Emulated { + _, why := lkEmulateNSD(h.rc) + msg = fmt.Sprintf("Забор заявок из ЛК (имитация НРД — %s): получено %d, прошли сверку и переданы имитатору %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected) + } + if len(res.Errors) > 0 { + msg += " · ошибки: " + strings.Join(res.Errors, "; ") + } + log.Printf("lk-gateway: ручной забор ЛК: %s", msg) + setupFlash(w, r, msg) +} + +// saveAlertsSMTP сохраняет настройки почтовых уведомлений. +func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Alerts + a.SMTP = SMTPSettings{ + Enabled: r.FormValue("enabled") == "on", + Host: strings.TrimSpace(r.FormValue("host")), + Port: atoiDefault(r.FormValue("port"), 587), + Security: nonEmpty(r.FormValue("security"), "starttls"), + Username: strings.TrimSpace(r.FormValue("username")), + Password: r.FormValue("password"), + From: strings.TrimSpace(r.FormValue("from")), + To: strings.TrimSpace(r.FormValue("to")), + LastMsg: a.SMTP.LastMsg, + LastAt: a.SMTP.LastAt, + } + if err := h.rc.UpdateAlerts(a); err != nil { + setupFlash(w, r, "SMTP: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Настройки SMTP сохранены") +} + +// testAlertsSMTP шлёт пробное письмо по текущим (сохранённым) настройкам. +func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Alerts + err := sendSMTP(a.SMTP, "[bj] Проверка уведомлений", "Это тестовое письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.") + a.SMTP.LastAt = time.Now() + if err != nil { + a.SMTP.LastMsg = "ошибка: " + err.Error() + _ = h.rc.UpdateAlerts(a) + setupFlash(w, r, "Тест SMTP: "+err.Error()) + return + } + a.SMTP.LastMsg = "тестовое письмо отправлено" + _ = h.rc.UpdateAlerts(a) + setupFlash(w, r, "Тест SMTP: письмо отправлено на "+a.SMTP.To) +} + +// saveAlertsZabbix сохраняет настройки Zabbix-хука. +func (h *setupHandlers) saveAlertsZabbix(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Alerts + a.Zabbix = ZabbixSettings{ + Enabled: r.FormValue("enabled") == "on", + Server: strings.TrimSpace(r.FormValue("server")), + Port: atoiDefault(r.FormValue("port"), 10051), + Host: strings.TrimSpace(r.FormValue("host")), + Key: nonEmpty(strings.TrimSpace(r.FormValue("key")), "bj.alert"), + LastMsg: a.Zabbix.LastMsg, + LastAt: a.Zabbix.LastAt, + } + if err := h.rc.UpdateAlerts(a); err != nil { + setupFlash(w, r, "Zabbix: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Настройки Zabbix сохранены") +} + +// testAlertsZabbix шлёт пробное значение на trapper-элемент Zabbix. +func (h *setupHandlers) testAlertsZabbix(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + a := h.rc.Snapshot().Alerts + info, err := sendZabbix(a.Zabbix, "bj: проверка хука оповещений") + a.Zabbix.LastAt = time.Now() + if err != nil { + a.Zabbix.LastMsg = "ошибка: " + err.Error() + _ = h.rc.UpdateAlerts(a) + setupFlash(w, r, "Тест Zabbix: "+err.Error()) + return + } + a.Zabbix.LastMsg = "принято: " + info + _ = h.rc.UpdateAlerts(a) + setupFlash(w, r, "Тест Zabbix: сервер принял значение ("+info+")") +} + +// saveIncomingNotify сохраняет настройки уведомлений депозитарию о входящих. +func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + s := IncomingNotifySettings{ + Enabled: r.FormValue("enabled") == "on", + Emails: strings.TrimSpace(r.FormValue("emails")), + EmailsBack: strings.TrimSpace(r.FormValue("emails_back")), + EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")), + Routes: map[string]string{}, + } + for _, k := range notifyKinds { + var gs []string + for _, g := range notifyGroups { + if r.FormValue("route_"+k.Key+"_"+g.Key) != "" { + gs = append(gs, g.Key) + } + } + s.Routes[k.Key] = strings.Join(gs, ",") + } + if err := h.rc.UpdateIncoming(s); err != nil { + setupFlash(w, r, "Уведомления: ошибка сохранения: "+err.Error()) + return + } + setupFlash(w, r, "Адреса и маршруты уведомлений сохранены") +} + +// testIncomingNotify шлёт пробное письмо-уведомление депозитарию по образцу +// реального входящего перевода. +func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + cfg := h.rc.Snapshot() + if strings.TrimSpace(cfg.Incoming.Recipients("incoming")) == "" { + setupFlash(w, r, "Тест: сначала укажите почту депозитария") + return + } + smtp := cfg.Alerts.SMTP + if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" { + setupFlash(w, r, "Тест: сначала настройте и включите SMTP (раздел «Почта»)") + return + } + sample := &IncomingTransfer{ + GUID: "00000000-0000-0000-0000-000000000000", + SenderCode: "MC0012500000", SenderINN: "7722061076", + ReceivedAt: time.Now(), + Securities: []IncomingSecurity{{SecurityCode: "RU0009029540", ISIN: "RU0009029540", Quantity: "10"}}, + } + smtp.To = cfg.Incoming.Recipients("incoming") + if err := sendSMTP(smtp, "[ТЕСТ] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil { + setupFlash(w, r, "Тест уведомления депозитарию: "+err.Error()) + return + } + setupFlash(w, r, "Тест: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("incoming")) +} + +// lkPostJSON — POST JSON к API ЛК с Basic; возвращает (код, тело). +func lkPostJSON(u string, ep LKEndpoint, body []byte) (int, string) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(body))) + if err != nil { + return 0, err.Error() + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if ep.Login != "" { + req.SetBasicAuth(ep.Login, ep.Password) + } + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + return 0, "нет связи: " + err.Error() + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return resp.StatusCode, strings.TrimSpace(string(b)) +} + +func atoiDefault(s string, d int) int { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + return d +} +func atofDefault(s string, d float64) float64 { + if f, err := strconv.ParseFloat(strings.TrimSpace(strings.ReplaceAll(s, ",", ".")), 64); err == nil { + return f + } + return d +} + // testRun запускает тестовую заявку с предустановленными данными, // ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет // результат в RuntimeConfig.LastTest. @@ -533,34 +973,55 @@ var robotDocumentNumbers = map[string]string{ "3333": "100000", // робот — принимающая сторона (one_settle_depo, шаг 1) } -// robotReferenceSecurity — одна бумага из эталонного набора робота НРД: -// ISIN и ИНН расчётного депозитария (SettlementRequisites). Набор одинаков -// во всех сценариях; робот различает их по ReferenceId (генерируется нами). +// robotReferenceSecurity — одна бумага эталонного набора робота НРД. +// code идёт в SecurityCode (КОД НРД бумаги — может отличаться от ISIN!), +// isin — в SecurityDetails. frac=true для бумаг с дробным количеством (паи ПИФ): +// у них количество указывается в Quantity/Fractional, а не Whole (иначе M2M27 +// «количество указано не в соответствующем поле»). type robotReferenceSecurity struct { + code string isin string - inn string + frac bool } -// robotReferenceSecurities — эталонный набор из 4 бумаг (m2m_robot_samples): -// две через расчётный депозитарий 7702165310, две — через 7831000034. +// robotReferenceSecurities — АКТУАЛЬНЫЙ список тест-бумаг TEST3 (ответ НРД от +// 15.08.2026 на обращение по M2M43 «Ценная бумага не учитывается в НРД»). +// ВАЖНО: у Сбербанка Код НРД (SecurityCode) = "SBER/03" ≠ ISIN "RU0009029540"; +// у паёв и облигаций код НРД совпадает с ISIN. Прежние бумаги (RU0007661625 и +// др.) сняты с учёта в TEST3 — давали M2M43. var robotReferenceSecurities = []robotReferenceSecurity{ - {isin: "RU0007661625", inn: "7702165310"}, - {isin: "RU000A0JP5V6", inn: "7702165310"}, - {isin: "RU000A0JPKH7", inn: "7831000034"}, - {isin: "RU000A0JPGP8", inn: "7831000034"}, + {code: "SBER/03", isin: "RU0009029540"}, // Акции ПАО Сбербанк (целое) + {code: "RU000A0JUR61", isin: "RU000A0JUR61", frac: true}, // Паи ИПИФ «КОНСЕРВАТОРия» — ДРОБНОЕ + {code: "RU000A102QL3", isin: "RU000A102QL3"}, // Облигации ВТБ СУБ-Т1-2 (целое) } -// buildRobotSecurities собирает эталонный набор бумаг с НАШИМИ депо-реквизитами -// списания (из настроек НРД) в SettlementLocation. -func buildRobotSecurities(depCode, accID, sectID string) []ClaimSec { - out := make([]ClaimSec, 0, len(robotReferenceSecurities)) - for _, s := range robotReferenceSecurities { +// buildRobotSecurities собирает count бумаг из эталонного набора с НАШИМИ +// депо-реквизитами списания. count<=0 → весь набор. Если count больше набора, +// бумаги берутся циклически (ReferenceID у каждой всё равно уникальный). +// SettlementRequisites.INN эталонного набора робота (ключ 1). А само место +// расчётов (SettlementDepositoryLocation) — НАШ счёт депо (depCode/accID/sectID +// из конфига НРД): именно так был построен подтверждённый роботом запрос +// c662544e (22.06.2026), где DeponentCode=MC0413600000. Реквизиты робота +// (MC0012500000/HL2603250011) в место расчётов НЕ ставим. +const robotSettleReqINN = "7702165310" + +func buildRobotSecurities(depCode, accID, sectID string, count int) []ClaimSec { + if count <= 0 { + count = len(robotReferenceSecurities) + } + out := make([]ClaimSec, 0, count) + for i := 0; i < count; i++ { + s := robotReferenceSecurities[i%len(robotReferenceSecurities)] + qty := Quantity{Whole: 1} + if s.frac { + qty = Quantity{Fractional: "1"} // паи — количество в дробном поле + } out = append(out, ClaimSec{ - SecurityCode: s.isin, // эталон робота: SecurityCode = ISIN + SecurityCode: s.code, SecurityDetails: SecurityDetails{ISIN: s.isin}, - Quantity: Quantity{Whole: 1}, + Quantity: qty, SettlementAccounts: []SettlementAccount{{ - SettlementRequisitesINN: s.inn, + SettlementRequisitesINN: robotSettleReqINN, SettlementLocation: SettlementLocation{ DeponentCode: depCode, AccountID: accID, SectionID: sectID, }, @@ -582,9 +1043,17 @@ func (h *setupHandlers) testNSD(w http.ResponseWriter, r *http.Request) { if _, ok := robotScenarios[scenario]; !ok { scenario = "2001" } + // Количество бумаг в пакете (симулятор). По умолчанию — весь набор. + secCount, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("securities_count"))) + if secCount < 1 { + secCount = 0 // 0 → весь эталонный набор + } + if secCount > 20 { + secCount = 20 + } // Отправляем синхронно, чтобы получить ID заявки и сразу открыть её карточку. // Ответ робота придёт асинхронно — его подхватит поллер входящих ИШ. - claimID, err := h.runRobotTest(scenario) + claimID, err := h.runRobotTest(scenario, secCount) if err != nil { setupFlash(w, r, fmt.Sprintf("Сценарий %s «%s»: отправка роботу не удалась: %v", scenario, robotScenarios[scenario], err)) @@ -598,49 +1067,39 @@ func (h *setupHandlers) testNSD(w http.ResponseWriter, r *http.Request) { // runRobotTest формирует и синхронно отправляет эталонную заявку роботу НРД. // Возвращает ID созданной заявки (для редиректа в карточку) и ошибку отправки. // Сам ответ робота приходит асинхронно — его подхватывает поллер входящих ИШ. -func (h *setupHandlers) runRobotTest(scenario string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) - defer cancel() - - // Депо-реквизиты клиента из настроек (откуда списываются бумаги). - // Если не заданы — подставляем плейсхолдеры (робот отвергнет по формату, - // но запрос уйдёт; реальные значения вводятся в админке → раздел НРД). +// buildRobotClaimRequest формирует эталонную заявку роботу НРД (общий код для +// прямой отправки в шлюз и для отправки через БД Fancy). +func (h *setupHandlers) buildRobotClaimRequest(scenario string, secCount int) CreateClaimRequest { nsd := h.rc.Snapshot().NSD depCode := nsd.DeponentCode accID := nsd.AccountID sectID := nsd.SectionID - - // DocumentNumber по сценарию (эталон m2m_robot_samples). Неизвестный - // сценарий — дефолт 2001. docNumber, ok := robotDocumentNumbers[scenario] if !ok { docNumber = robotDocumentNumbers["2001"] } - - req := CreateClaimRequest{ + return CreateClaimRequest{ Investor: Investor{ ID: "22222222-2222-2222-2222-222222222222", LastName: "Толкин", FirstName: "Никита", MiddleName: "Анатольевич", - // Series = код сценария робота, Number = вариант теста (эталон НРД). Document: Document{DocumentType: "21", Series: scenario, Number: docNumber}, }, - // Списывающий (передающий) депозитарий — НАША организация. ИНН должен - // совпадать с записью нашего участника M2M (MC0413600000 = ООО ИК - // «Фонтвьель», ИНН 7703807489), иначе НРД отвечает M2M17 «неверный ИНН - // списывающего депозитария». TransferringDepositoryINN: "7703807489", - ReceivingDepositoryINN: "7722061076", // принимающий депозитарий из набора робота - // Эталон робота: CostInfo=Yes с НАШИМ депкодом (код передачи налоговой - // информации сверяется со справочником участников M2M). - CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}}, - Securities: buildRobotSecurities(depCode, accID, sectID), - SignedDocument: "dGVzdC1zaWduYXR1cmU=", - SignatureFormat: "XMLDSig-GOST", - ReceiverCodeOverride: NSDRobotCode, // отправляем роботу, не дефолтному получателю - // Серия ДУЛ = код сценария, номер = вариант теста. Иначе паспорт - // берётся из анкеты и робот не распознаёт сценарий. - InvestorDocumentOverride: &Document{DocumentType: "21", Series: scenario, Number: docNumber}, + ReceivingDepositoryINN: "7722061076", + CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}}, + Securities: buildRobotSecurities(depCode, accID, sectID, secCount), + SignedDocument: "dGVzdC1zaWduYXR1cmU=", + SignatureFormat: "XMLDSig-GOST", + ReceiverCodeOverride: NSDRobotCode, + InvestorDocumentOverride: &Document{DocumentType: "21", Series: scenario, Number: docNumber}, } +} + +func (h *setupHandlers) runRobotTest(scenario string, secCount int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + + req := h.buildRobotClaimRequest(scenario, secCount) res := TestRunResult{StartedAt: time.Now().UTC()} created, err := h.svc.CreateClaim(ctx, req) @@ -670,10 +1129,16 @@ func (h *setupHandlers) saveLicense(w http.ResponseWriter, r *http.Request) { return } cfg := h.rc.Snapshot().License - cfg.Key = strings.TrimSpace(r.FormValue("key")) - if pk := strings.TrimSpace(r.FormValue("public_key")); pk != "" { - cfg.PublicKey = pk + key := strings.Join(strings.Fields(r.FormValue("key")), "") + if key == "" { + setupFlash(w, r, "Лицензия: вставьте офлайн-ключ (пустое поле действующую лицензию не меняет)") + return } + if _, err := verifyLicenseToken(key); err != nil { + setupFlash(w, r, "Лицензия: ключ не принят — подпись не сходится или ключ повреждён") + return + } + cfg.Key = key if err := h.rc.SaveLicense(cfg); err != nil { setupFlash(w, r, "лицензия: ошибка сохранения: "+err.Error()) return @@ -686,6 +1151,42 @@ func (h *setupHandlers) saveLicense(w http.ResponseWriter, r *http.Request) { } } +// licenseActivate — POST /admin/setup/license/activate: активация установки кодом на сервере лицензий. +func (h *setupHandlers) licenseActivate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + code := strings.TrimSpace(r.FormValue("code")) + if code == "" { + setupFlash(w, r, "Лицензия: введите код активации") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + if err := LicenseActivate(ctx, h.rc, code); err != nil { + setupFlash(w, r, "Лицензия: активация не удалась — "+err.Error()) + return + } + st := licenseStatus(h.rc) + setupFlash(w, r, fmt.Sprintf("Лицензия активирована: %s, план %s, %s", st.Tenant, st.Plan, st.Message)) +} + +// licenseRenewNow — POST /admin/setup/license/renew: продлить ключ сейчас. +func (h *setupHandlers) licenseRenewNow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) + defer cancel() + if err := LicenseRenew(ctx, h.rc); err != nil { + setupFlash(w, r, "Лицензия: "+err.Error()) + return + } + setupFlash(w, r, "Лицензия: "+h.rc.Snapshot().License.LastMsg) +} + // saveUpdate — POST /admin/setup/update. Сохраняет настройки обновлений. func (h *setupHandlers) saveUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -850,19 +1351,61 @@ func tryHTTPHealth(u string) error { // POST-действия (нажал кнопку «Проверить обновления» в Новостях — должен // остаться в Новостях со флешем). func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { - if ref := r.Header.Get("Referer"); ref != "" { - if u, err := url.Parse(ref); err == nil { - for _, prefix := range []string{"/admin/wizard", "/admin/news", "/admin/setup"} { - if strings.HasPrefix(u.Path, prefix) { - q := u.Query() - q.Set("flash", msg) - http.Redirect(w, r, u.Path+"?"+q.Encode(), http.StatusSeeOther) - return - } + // Куда вернуться: на ту же страницу, где была форма. Берём из скрытого поля + // return_to (его проставляет JS из location.pathname) — это надёжнее Referer, + // который браузер по plain-HTTP/политике может не слать. Фолбэки: Referer, + // затем /admin/setup. + dest := safeReturnPath(r.FormValue("return_to")) + if dest == "" { + if ref := r.Header.Get("Referer"); ref != "" { + if u, err := url.Parse(ref); err == nil && safeReturnPath(u.Path) != "" { + dest = u.Path } } } - http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther) + if dest == "" { + dest = "/admin/setup" + } + q := url.Values{} + q.Set("flash", msg) + // Секция (id карточки) — через ?scrollto=, JS прокрутит к ней и уберёт параметр. + if s := r.FormValue("section"); s != "" && isAnchorName(s) { + q.Set("scrollto", s) + } + http.Redirect(w, r, dest+"?"+q.Encode(), http.StatusSeeOther) +} + +// safeReturnPath пропускает только наши же admin-пути ("/admin/..."), без +// схемы/хоста и без "//" — защита от open-redirect. Пусто, если путь небезопасен. +func safeReturnPath(p string) string { + if p == "" || len(p) > 256 { + return "" + } + if !strings.HasPrefix(p, "/admin") { + return "" + } + if strings.HasPrefix(p, "//") || strings.ContainsAny(p, ":\\\r\n") { + return "" + } + // только путь, без query/fragment + if i := strings.IndexAny(p, "?#"); i >= 0 { + p = p[:i] + } + return p +} + +// isAnchorName пропускает только безопасные id-якоря (буквы/цифры/-/_), +// чтобы не дать заголовкам ответа инжектить произвольное. +func isAnchorName(s string) bool { + if s == "" || len(s) > 64 { + return false + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') { + return false + } + } + return true } // _q извлекает Request из ResponseWriter trick — здесь не нужно @@ -870,242 +1413,6 @@ func setupFlash(w http.ResponseWriter, r *http.Request, msg string) { func _q(_ http.ResponseWriter) string { return "" } func errMsgFromQuery(_ string) string { return "" } -// uploadISO — POST /admin/setup/media/iso/upload (multipart). -// Принимает .iso или .img файл, сохраняет в /var/lib/bj/iso/-, -// распаковывает через 7z в /var/lib/bj/media/iso//. -func (h *setupHandlers) uploadISO(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - // Лимит 500 МБ. - if err := r.ParseMultipartForm(500 << 20); err != nil { - setupFlash(w, r, "Образ: ошибка чтения формы: "+err.Error()) - return - } - file, header, err := r.FormFile("iso") - if err != nil { - setupFlash(w, r, "Образ: выберите файл .iso или .img") - return - } - defer file.Close() - lower := strings.ToLower(header.Filename) - if !(strings.HasSuffix(lower, ".iso") || strings.HasSuffix(lower, ".img") || - strings.HasSuffix(lower, ".zip") || strings.HasSuffix(lower, ".7z")) { - setupFlash(w, r, "Образ: расширение файла должно быть .iso, .img, .zip или .7z") - return - } - password := r.FormValue("password") - isoDir := "/var/lib/bj/iso" - if err := os.MkdirAll(isoDir, 0o755); err != nil { - setupFlash(w, r, "Образ: не удалось создать "+isoDir+": "+err.Error()) - return - } - safeName := filepath.Base(header.Filename) - dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+safeName) - out, err := os.Create(dst) - if err != nil { - setupFlash(w, r, "Образ: запись: "+err.Error()) - return - } - if _, err := io.Copy(out, file); err != nil { - out.Close() - _ = os.Remove(dst) - setupFlash(w, r, "Образ: запись файла: "+err.Error()) - return - } - out.Close() - - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) - defer cancel() - m, err := ExtractISO(ctx, dst, password) - if err != nil { - setupFlash(w, r, "Образ: распаковка: "+err.Error()) - return - } - setupFlash(w, r, fmt.Sprintf("Образ «%s» загружен и распакован: %d контейнер(ов), %d сертификат(ов)", - safeName, len(m.Containers), len(m.Certificates))) -} - -// unmountISO — POST /admin/setup/media/iso/unmount. Удаляет распакованную ISO. -func (h *setupHandlers) unmountISO(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - id := strings.TrimSpace(r.FormValue("id")) - if id == "" { - setupFlash(w, r, "Образ: не указан id") - return - } - if err := UnmountISO(id); err != nil { - setupFlash(w, r, "Образ: удаление не удалось — "+err.Error()) - return - } - setupFlash(w, r, "Образ удалён полностью: распаковка из /var/lib/bj/media/iso/"+id+" и исходный файл .img/.iso из /var/lib/bj/iso/.") -} - -// importContainer — POST /admin/setup/media/import-container. -// Принимает path — путь до директории с *.key. Копирует в -// /var/lib/bj/containers//, при успехе зовёт crypto-service для -// импорта контейнера в Валидата-профиль (если сайдкар запущен). -func (h *setupHandlers) importContainer(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - src := strings.TrimSpace(r.FormValue("path")) - if src == "" { - setupFlash(w, r, "Импорт контейнера: не указан path") - return - } - // Защита: путь должен быть внутри известных media-roots. - if !allowedMediaPath(src) { - setupFlash(w, r, "Импорт контейнера: путь вне допустимых зон (USB/ISO)") - return - } - dst, err := ImportKeyContainer(src) - if err != nil { - setupFlash(w, r, "Импорт контейнера: "+err.Error()) - return - } - setupFlash(w, r, "Контейнер скопирован в "+dst+". Перезапустите bj-crypto с BJ_VALIDATA_PROFILE — Валидата подхватит контейнер.") -} - -// importProfile — POST /admin/setup/media/import-profile. -// Параметры: root (путь до корня профиля), name (произвольное имя). -// Копирует pse/gdbm/vdkeys в /var/lib/bj/profiles//. -func (h *setupHandlers) importProfile(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - root := strings.TrimSpace(r.FormValue("root")) - name := strings.TrimSpace(r.FormValue("name")) - if root == "" { - setupFlash(w, r, "Импорт профиля: не указан root") - return - } - if !allowedMediaPath(root) { - setupFlash(w, r, "Импорт профиля: путь вне допустимых зон (USB/ISO)") - return - } - res, err := ImportProfile(root, name) - if err != nil { - setupFlash(w, r, "Импорт профиля: "+err.Error()) - return - } - profileName := filepath.Base(res.Path) - if res.ConfWritten { - setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+ - ". Секция дописана в /opt/Validata/VDCSP/etc/pki1.conf. "+ - "Теперь нажмите «Активировать» в таблице ниже — Валидата переключится на профиль БЕЗ перезапуска сайдкара.") - } else { - setupFlash(w, r, "Профиль «"+profileName+"» импортирован: "+res.Path+ - ". НО в pki1.conf дописать не удалось ("+res.ConfWriteError+"). "+ - "Готовая секция лежит в "+res.Path+"/pki1.conf-section.txt — "+ - "допишите её через sudo, потом нажмите «Активировать».") - } -} - -// activateProfile — POST /admin/setup/media/activate-profile. -// Параметр: name — имя профиля из pki1.conf. Делает gRPC-вызов -// crypto-service.Activate(name) — без рестарта сайдкара. После -// успеха сохраняет имя в runtime-конфиге. -func (h *setupHandlers) activateProfile(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - name := strings.TrimSpace(r.FormValue("name")) - s := h.rc.Snapshot() - cli := cryptocli.New(cryptocli.Config{ - Provider: cryptocli.Provider(s.Crypto.Provider), - SocketPath: s.Crypto.SocketPath, - }) - defer cli.Close() - res, err := cli.Activate(r.Context(), name) - if err != nil { - setupFlash(w, r, "Активация: "+err.Error()) - return - } - if !res.OK { - setupFlash(w, r, "Активация не удалась: "+res.Message) - return - } - // Сохраняем активный профиль в runtime-конфиге (для UI и для - // автозагрузки при следующем рестарте сайдкара). - cur := h.rc.Snapshot().Crypto - cur.Profile = name - if err := h.rc.UpdateCrypto(cur); err != nil { - log.Printf("activateProfile: UpdateCrypto: %v", err) - } - if name == "" { - setupFlash(w, r, "Профиль сброшен — Валидата в minimal-режиме. "+res.Message) - } else { - setupFlash(w, r, "Профиль «"+name+"» активирован: "+res.Message) - } -} - -// deleteContainer — POST /admin/setup/media/delete-container. -// Параметр: name — имя контейнера в /var/lib/bj/containers/. -func (h *setupHandlers) deleteContainer(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - name := strings.TrimSpace(r.FormValue("name")) - if name == "" { - setupFlash(w, r, "Удаление контейнера: не указано имя") - return - } - if err := DeleteImportedContainer(name); err != nil { - setupFlash(w, r, "Удаление контейнера: "+err.Error()) - return - } - setupFlash(w, r, "Контейнер «"+name+"» удалён из /var/lib/bj/containers/.") -} - -// deleteProfile — POST /admin/setup/media/delete-profile. -// Параметр: name — имя профиля в /var/lib/bj/profiles/. -// Сносит директорию + чистит секцию из pki1.conf. Если профиль был -// активным — переключает crypto-service в minimal через Activate(""). -func (h *setupHandlers) deleteProfile(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method", http.StatusMethodNotAllowed) - return - } - name := strings.TrimSpace(r.FormValue("name")) - if name == "" { - setupFlash(w, r, "Удаление профиля: не указано имя") - return - } - s := h.rc.Snapshot() - wasActive := s.Crypto.Profile == name - if wasActive { - // Сначала переключаем сайдкар в minimal — иначе он держит - // открытый контекст на файлы, которые мы сейчас удалим. - cli := cryptocli.New(cryptocli.Config{ - Provider: cryptocli.Provider(s.Crypto.Provider), - SocketPath: s.Crypto.SocketPath, - }) - _, _ = cli.Activate(r.Context(), "") - _ = cli.Close() - cur := s.Crypto - cur.Profile = "" - _ = h.rc.UpdateCrypto(cur) - } - if err := DeleteImportedProfile(name); err != nil { - setupFlash(w, r, "Удаление профиля: "+err.Error()) - return - } - if wasActive { - setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf). Был активным — crypto-service переведён в minimal.") - } else { - setupFlash(w, r, "Профиль «"+name+"» удалён (директория, секция в pki1.conf).") - } -} - // restartCrypto — POST /admin/setup/restart-crypto. Отправляет // gRPC.Shutdown сайдкару — он завершится с exit(2), systemd // поднимет его через RestartSec=5. @@ -1143,21 +1450,4 @@ func (h *setupHandlers) restartServer(w http.ResponseWriter, r *http.Request) { }() } -// allowedMediaPath проверяет, что путь к контейнеру внутри одной из -// известных media-точек: USB-mount, наша /var/lib/bj/media/iso/. -func allowedMediaPath(p string) bool { - abs, err := filepath.Abs(p) - if err != nil { - return false - } - allowed := []string{"/run/media/", "/media/", "/mnt/", "/var/lib/bj/media/iso/"} - for _, prefix := range allowed { - if strings.HasPrefix(abs, prefix) { - return true - } - } - return false -} - -// guard — заглушка для совместимости с возможным расширением. var _ = errors.New diff --git a/internal/lkgateway/types.go b/internal/lkgateway/types.go index fa94ef9..f897772 100644 --- a/internal/lkgateway/types.go +++ b/internal/lkgateway/types.go @@ -25,6 +25,9 @@ type CreateClaimRequest struct { // InvestorDocumentOverride — если задан, переопределяет документ инвестора // из анкеты. Используется тестом с роботом НРД (серия ДУЛ = код сценария). InvestorDocumentOverride *Document `json:"investor_document_override,omitempty"` + // ClientAccount — торговый счёт депо клиента из заявки (ЛК): сверяется с DEPO — + // должен принадлежать найденному по паспорту инвестору и быть открыт. + ClientAccount string `json:"client_account,omitempty"` } // Investor — анкета. @@ -34,6 +37,7 @@ type Investor struct { FirstName string `json:"first_name"` MiddleName string `json:"middle_name,omitempty"` Document Document `json:"document"` + BirthDate string `json:"birth_date,omitempty"` // ГГГГ-ММ-ДД (из карточки пользователя ЛК), сверяется с DEPO } // Document — удостоверение личности. @@ -141,6 +145,46 @@ type ClaimView struct { M2MGUID m2m.UUID `json:"m2m_guid,omitempty"` M2MResponse *NSDResponseView `json:"m2m_response,omitempty"` M2MDecision *NSDDecisionView `json:"m2m_decision,omitempty"` + // RequestRawXML — точный M2MTransferRequest, отправленный в НРД + // (декодирован в UTF-8). Чтобы в карточке видеть отправленный пакет. + RequestRawXML string `json:"request_raw_xml,omitempty"` + // TestResult — для тестовых заявок роботу НРД: что ожидали по сценарию, + // что получили, и совпало ли. Nil для обычных (не тестовых) заявок. + TestResult *TestResultView `json:"test_result,omitempty"` + // Timeline — путь заявки во времени: положена в БД Fancy → вердикт «крана» + // → отправлена в шлюз НРД → ответ НРД. С длительностями между этапами. + Timeline []TimelineEvent `json:"timeline,omitempty"` +} + +// TimelineEvent — один шаг в истории движения заявки. +type TimelineEvent struct { + Title string `json:"title"` // что произошло + Time time.Time `json:"time"` // когда + Detail string `json:"detail,omitempty"` // подробность (кто/что/причина) + Since string `json:"since,omitempty"` // сколько прошло с предыдущего шага (человекочит.) + Kind string `json:"kind"` // db | crane | gateway | response | reject +} + +// TestResultView — самопроверка тестовой заявки роботу НРД: сценарий, +// ожидаемый и фактический результат, вердикт. +type TestResultView struct { + Scenario string `json:"scenario"` // 1111 / 2001 / 2002 / 3333 + Title string `json:"title"` // человекочитаемое название сценария + Expected string `json:"expected"` // что ждём от робота + Got string `json:"got"` // что реально пришло + Status string `json:"status"` // ok | mismatch | waiting | error + Note string `json:"note,omitempty"` // пояснение +} + +// statusFromBool ставит ok/mismatch и пояснение по результату сравнения. +func (t *TestResultView) statusFromBool(ok bool, okNote, badNote string) { + if ok { + t.Status = "ok" + t.Note = okNote + } else { + t.Status = "mismatch" + t.Note = badNote + } } // StageView — точка истории FSM для UI. diff --git a/internal/lkgateway/web/quikexport/export.ini.tmpl b/internal/lkgateway/web/quikexport/export.ini.tmpl new file mode 100644 index 0000000..ff304dd --- /dev/null +++ b/internal/lkgateway/web/quikexport/export.ini.tmpl @@ -0,0 +1,1301 @@ +[DB] +DSN=quik_export +User= +Password= + +[Service] +name=ExportService +displayname=Export Service + +[QUIK Server] +User= +Password= + +[General] +DebugLevel=1 +StatisticPeriod=60 +Memory=10 +ParamPeriod=30 +PortfolioPeriod=60 +UpdateDealLibs=0 +LimitKinds=0, 1, 365 + +;PortfolioTimeout=60 +[ErrorMail] +MailServer= +MailFromAddress= +MailToAddress= +MailSubject=XReport export error log +MailPort=25 + +[Output tables] +;Classes=Classes +;Securities=Securities +;Trades=Trades +;AllTrades=AllTrades +;Orders=Orders +;Firms=Firms +;Params=Params +;DepoLimits=DepoLimits +;MoneyLimits=MoneyLimits +;UsTrades=UsTrades +;NegDeals=NegDeals +;NegReports=NegReports +;Calendars=Calendars +;CCPHold=CCPHold +;Trdaccs=Trdaccs +;AccountPositions=AccountPositions +;FirmDepoAccount=FirmDepoAccount +;SecDepoAccount=SecDepoAccount +;StopOrders=StopOrders +;Quotes=Quotes +;FuturesHolding=FuturesHolding +;FuturesLimits=FuturesLimits +Portfolio=Portfolio +;CCPIndicative=CCPIndicative +;CCPPriceRange=CCPPriceRange +;MarketMakerStats=MarketMakerStats +;MarketMakerForts=MarketMakerForts +;MarketMakerExtStats=MarketMakerExtStats +;RmHold=RmHold +;RmIndicative=RmIndicative +;RmPriceRange=RmPriceRange +;RmPriceRangeFirm=RmPriceRangeFirm +;SecAccIntEx=SecAccIntEx +;RmProductSpreadCurr=RmProductSpreadCurr +;AlgoOrders=AlgoOrders +;AlgoLinkedOrders=AlgoLinkedOrders +;AlgoLinkedTrades=AlgoLinkedTrades +;SecuritiesDictionary=SecuritiesDictionary +;TransResult=TransResult +;SettleCodes=SettleCodes +;NCCTransfers=NCCTransfers +;Prohibitions=Prohibitions +;CurrencyInfo=CurrencyInfo +;MoneyLimitsJournal=MoneyLimitsJournal +;DepoLimitsJournal=DepoLimitsJournal +;FuturesHoldingJournal=FuturesHoldingJournal + +[ClearTables] +Classes=1 +Securities=1 +Trades=1 +AllTrades=1 +Orders=1 +Params=1 +DepoLimits=1 +MoneyLimits=1 +UsTrades=1 +NegDeals=1 +NegReports=1 +;FuturesHolding=1 +;FuturesLimits=1 +;AccountPositions=1 +;FirmDepoAccount=1 +;SecDepoAccount=1 +;StopOrders=1 +;Quotes=1 + + +[Classes] +TradeDate=TradeDate +ClassCode=ClassCode +ClassName=ClassName +FirmsId=FirmsId +RFlags=RFlags +ClassType=ClassType +ClassSubType=ClassSubType +CommonBehavior=CommonBehavior +SpecificBehavior=SpecificBehavior + + +[Firms] +Exchange=Exchange +FirmId=FirmId +FirmName=FirmName +Status=Status +StatusFlag=StatusFlag + +[Securities] +TradeDate=TradeDate +ClassCode=ClassCode +SecCode=SecCode +SecType=SecType +ClassName=ClassName +FirmsId=FirmId +RFlags=RFlags +FaceValue=FaceValue +OptionStrike=OptionStrike +OptionType=OptionType +OptionBaseSecCode=OptionBaseSecCode +OptionBase=OptionBase +OptionBaseClassCode=OptionBaseClassCode +LotSize=LotSize +MatDate=MatDate +DaysToMatDate=DaysToMatDate +MinStep=MinStep +Scale=Scale +FaceUnit=FaceUnit +SecShortName=SecShortName +SecFullName=SecFullName +SecName=SecName +ISINCode=ISINCode +RegNumber=RegNumber +Leg1ClassCode=Leg1ClassCode +Leg1SecCode=Leg1SecCode +Leg1CFI=Leg1CFI +Leg1RatioQty=Leg1RatioQty +Leg1Side=Leg1Side +Leg1SettleDate=Leg1SettleDate +Leg2ClassCode=Leg2ClassCode +Leg2SecCode=Leg2SecCode +Leg2CFI=Leg2CFI +Leg2RatioQty=Leg2RatioQty +Leg2Side=Leg2Side +Leg2SettleDate=Leg2SettleDate + +[Trades] +TradeNum=TradeNum +Operation=Operation +ClassCode=ClassCode +TradeDate=TradeDate +SecCode=SecCode +Accruedint=Accruedint +Accruedint2=Accruedint2 +LowerDiscount=LowerDiscount +OrderNum=OrderNum +Price=Price +Price2=Price2 +RepoRate=RepoRate +RepoValue=RepoValue +Repo2Value=Repo2Value +StartDiscount=StartDiscount +TSCommission=TSCommission +UpperDiscount=UpperDiscount +Value=Value +Yield=Yield +Qty=Qty +TradeDateTime=TradeDateTime +BlockSecurities=BlockSecurities +RepoTerm=RepoTerm +ClearingCommission=ClearingCommission +ExchangeCommission=ExchangeCommission +TechCenterCommission=TechCenterCommission +Account=Account +BrokerRef=BrokerRef +ClientCode=ClientCode +SettleCode=SettleCode +UserId=UserId +ExchangeCode=ExchangeCode +Type=Type +FirmId=FirmId +FirmName=FirmName +CPFirmId=CPFirmId +CPFirmName=CPFirmName +ClassName=ClassName +SecName=SecName +SettleDate=SettleDate +SettleCurrency=SettleCurrency +TradeCurrency=TradeCurrency +StationId=StationId +BrokerCommission=BrokerCommission +Kind=Kind +FirstCurrCode=FirstCurrCode +FirstCurrQty=FirstCurrQty +SecondCurrCode=SecondCurrCode +SecondCurrQty=SecondCurrQty +Benchmark=Benchmark + +[AllTrades] +TradeNum=TradeNum +ClassCode=ClassCode +TradeDate=TradeDate +Accruedint=Accruedint +Price=Price +Value=Value +Yield=Yield +RepoValue=RepoValue +Repo2Value=Repo2Value +Qty=Qty +;TradeTime=TradeTime +AllTradeDateTime=AllTradeDateTime +RepoTerm=RepoTerm +SettleCode=SettleCode +Period=Period +SecName=SecName +SecCode=SecCode +ClassName=ClassName +Operation=Operation +Benchmark=Benchmark + +[Orders] +OrderNum=OrderNum +ClassCode=ClassCode +TradeDate=TradeDate +Accruedint=Accruedint +Price=Price +Value=Value +Yield=Yield +Price2=Price2 +Balance=Balance +Qty=Qty +UID=UID +OrderDateTime=OrderDateTime +WithdrawDateTime=WithdrawDateTime +Account=Account +BrokerRef=BrokerRef +ClientCode=ClientCode +UserId=UserId +SettleCode=SettleCode +Operation=Operation +State=State +Type=Type +FirmId=FirmId +FirmName=FirmName +ExchangeCode=ExchangeCode +SecName=SecName +SecCode=SecCode +ClassName=ClassName +ActivationTime=ActivationTime +LinkedOrder=LinkedOrder +Expiry=Expiry +IsMarketMaker=IsMarketMaker +FirstCurrCode=FirstCurrCode +FirstCurrQty=FirstCurrQty +SecondCurrCode=SecondCurrCode +SecondCurrQty=SecondCurrQty +Benchmark=Benchmark + +[FuturesHolding] +FirmId=FirmId +SecCode=SecCode +TrdAccId=TrdAccId +Type=Type +TradeDate=TradeDate +AvrPosnPrice=AvrPosnPrice +CbplPlanned=CbplPlanned +CbplUsed=CbplUsed +Varmargin=Varmargin +PositionValue=PositionValue +OpenBuys=OpenBuys +OpenSells=OpenSells +StartBuy=StartBuy +StartNet=StartNet +StartSell=StartSell +TodayBuy=TodayBuy +TodaySell=TodaySell +TotalNet=TotalNet +FirmName=FirmName +SecName=SecName + +[FuturesLimits] +FirmId=FirmId +TrdAccId=TrdAccId +Type=Type +CurrCode=CurrCode +LoadDate=LoadDate +Accruedint=Accruedint +CbpLimit=CbpLimit +CbplPlanned=CbplPlanned +CbplUsed=CbplUsed +CbpPrevLimit=CbpPrevLimit +LiquidityCoeff=LiquidityCoeff +Varmargin=Varmargin +CbplusedForOrders=CbplusedForOrders +CbplusedForPositions=CbplusedForPositions +OptionsPremium=OptionsPremium +TsCommission=TsCommission +Kgo=Kgo +FirmName=FirmName + +[Portfolio] +IncludeDerivativesMarketClients=1 +FirmID=FirmId +ClientCode=ClientCode +LimitKind=LimitKind +CurrCode=CurrCode +Tag=Tag +MarginType=MarginType +PortfolioValue=PortfolioValue +InitMargin=InitMargin +MinMargin=MinMargin +CorrectedMargin=CorrectedMargin +RCV1=RCV1 +RCV2=RCV2 +FundsLevel=FundsLevel +Status=Status +RiskLevel=RiskLevel + +[MoneyLimitsJournal] +FirmID=FirmID +ClientCode=ClientCode +Tag=Tag +CurrCode=CurrCode +LimitKind=LimitKind +CalcDateTime=CalcDateTime +CurrentBal=CurrentBal +CrossRate=CrossRate +LongDiscount=LongDiscount +ShortDiscount=ShortDiscount + +[DepoLimitsJournal] +FirmID=FirmID +ClientCode=ClientCode +SecCode=SecCode +TrdAcc=TrdAcc +LimitKind=LimitKind +CalcDateTime=CalcDateTime +CurrentBal=CurrentBal +ValuationClassCode=ValuationClassCode +Price=Price +PriceCurrCode=PriceCurrCode +CrossRate=CrossRate +LongDiscount=LongDiscount +ShortDiscount=ShortDiscount + +[FuturesHoldingJournal] +FirmID=FirmID +SecCode=SecCode +TrdaccID=TrdaccID +Type=Type +CalcDateTime=CalcDateTime +ClientCode=ClientCode +CurrentBal=CurrentBal +VarMargin=VarMargin +ValuationClassCode=ValuationClassCode +SettlePrice=SettlePrice +SettlePriceCurrCode=SettlePriceCurrCode +CrossRate=CrossRate +LongDiscount=LongDiscount +ShortDiscount=ShortDiscount +Volatility=Volatility + +[Params] +ClassCode=ClassCode +SecCode=SecCode +TradeDate=TradeDate +accruedint=accruedint +bid=bid +biddepth=biddepth +biddeptht=biddeptht +buydepo=buydepo +buyprofit=buyprofit +change=change +changetime=changetime +chngclose=chngclose +chngopen=chngopen +closeprice=closeprice +closetime=closetime +closeval=closeval +closeyield=closeyield +couponvalue=couponvalue +currentvalue=currentvalue +curval=curval +high=high +highbid=highbid +last=last +lastchange=lastchange +lasttoprevstlprc=lasttoprevstlprc +lastvalue=lastvalue +;longname=longname +Securities.LotSize=lotsize +low=low +lowoffer=lowoffer +marketprice=marketprice +marketpricetoday=marketpricetoday +maxoutvolume= maxoutvolume +negnumtrades=negnumtrades +negvaltoday=negvaltoday +numbids=numbids +numcontracts=numcontracts +numoffers=numoffers +numtrades=numtrades +offer=offer +offerdepth=offerdepth +offerdeptht=offerdeptht +open=[open] +openval=openval +prevprice=prevprice +prevwaprice=prevwaprice +pricemax=pricemax +pricemin=pricemin +priceminusprevwa=priceminusprevwa +pricemvtlimit=pricemvtlimit +pricemvtlimitt1=pricemvtlimitt1 +primarydist=primarydist +qty=qty +repo2price=repo2price +selldepo=selldepo +sellprofit=sellprofit +status=status +steppricet=steppricet +time=time +theorprice=theorprice +tradechange=tradechange +tradingstatus=tradingstatus +valtoday=valtoday +value=value +volatility=volatility +voltoday=voltoday +waprice=waprice +yield=yield +yieldatprevwapri=yieldatprevwapri +yieldatwaprice=yieldatwaprice +bgonp=bgonp +bgop=bgop +highval=highval +ichange=ichange +iopen=iopen +lowval=lowval +pchange=pchange +quantity=quantity +settleprice=settleprice +Securities.OptionStrike=strike +nextcoupon=nextcoupon +admittedquote=admittedquote +issuesize=issuesize +prevdate=prevdate +;lopenprice=lopenprice +lcurrentprice=lcurrentprice +quotebasis=quotebasis +couponperiod=couponperiod +buybackprice=buybackprice +buybackdate=buybackdate +duration=duration +Securities.ExpDate=expdate +Securuties.OptionType=optiontype + +[MoneyLimits] +LoadDate=LoadDate +ClientCode=ClientCode +CurrCode=CurrCode +FirmId=FirmId +Tag=Tag +Leverage=Leverage +LockedValueCoef=LockedValueCoef +CurrentBal=CurrentBal +CurrentLimit=CurrentLimit +Locked=Locked +OpenBal=OpenBal +OpenLimit=OpenLimit +FirmName=FirmName +LimitKind=LimitKind + +[DepoLimits] +ClientCode=ClientCode +SecCode=SecCode +FirmId=FirmId +Trdacc=Trdacc +LoadDate=LoadDate +LockedSellValue=LockedSellValue +LockedBuy=LockedBuy +LockedBuyValue=LockedBuyValue +CurrentBal=CurrentBal +CurrentLimit=CurrentLimit +LockedSell=LockedSell +OpenBal=OpenBal +OpenLimit=OpenLimit +FirmName=FirmName +SecName=SecName +AwgPositionPrice=AwgPositionPrice +LimitKind=LimitKind + +[UsTrades] +TradeNum=TradeNum +TradeDate=TradeDate +ClassCode=ClassCode +Operation=Operation +Accruedint=Accruedint +CPReportNum=CPReportNum +Price=Price +Price1=Price1 +Price2=Price2 +RepoRate=RepoRate +RepoTradeNum=RepoTradeNum +Value=Value +Amount=Amount +ExpectedRepo2Value=ExpectedRepo2Value +ExpectedRepoValue=ExpectedRepoValue +ExpectedReturnValue=ExpectedReturnValue +Repo2Value=Repo2Value +RepoValue=RepoValue +ReturnValue=ReturnValue +TSCommission=TSCommission +Discount=Discount +ExpectedDiscount=ExpectedDiscount +LowerDiscount=LowerDiscount +UpperDiscount=UpperDiscount +Qty=Qty +UsTradeDate=UsTradeDate +Balance=Balance +UsTradeSettleDateTime=UsTradeSettleDateTime +ExpectedQuantity=ExpectedQuantity +ReportTradeDate=ReportTradeDate +BlockSecurities=BlockSecurities +OperationType=OperationType +RepoTerm=RepoTerm +Type=Type +UrgencyFlags=UrgencyFlags +Account=Account +BrokerRef=BrokerRef +CPAccount=CPAccount +CPFirmId=CPFirmId +FirmId=FirmId +FirmName=FirmName +SettleCode=SettleCode +ClientCode=ClientCode +DealState=DealState +ReportNum=ReportNum +ClassName=ClassName +SecName=SecName +SecCode=SecCode +Settled=Settled +ClearingType=ClearingType +;ReportCommission=ReportCommission +CouponPayment=CouponPayment +CouponPaymentDate=CouponPaymentDate +PrincipalPayment=PrincipalPayment +PrincipalPaymentDate=PrincipalPaymentDate +Benchmark=Benchmark +BenchmarkChangeDate=BenchmarkChangeDate +BenchmarkValue=BenchmarkValue + +[NegDeals] +NegDealNum=NegDealNum +TradeDate=TradeDate +ClassCode=ClassCode +Operation=Operation +Accruedint=Accruedint +Price=Price +Price2=Price2 +Qty=Qty +RepoRate=RepoRate +RefundRate=RefundRate +Value=Value +Yield=Yield +LowerDiscount=LowerDiscount +StartDiscount=StartDiscount +UpperDiscount=UpperDiscount +Repo2Value=Repo2Value +RepoValue=RepoValue +OriginRepoValue=OriginRepoValue +OriginDiscount=OriginDiscount +UID=UID +NegDealDateTime=NegDealDateTime +WithdrawDateTime=WithdrawDateTime +Balance=Balance +OriginQty=OriginQty +BlockSecurities=BlockSecurities +RepoEntry=RepoEntry +RepoTerm=RepoTerm +Account=Account +BrokerRef=BrokerRef +CPFirmId=CPFirmId +CPFirmName=CPFirmName +CPUserId=CPUserId +FirmId=FirmId +FirmName=FirmName +MatchRef=MatchRef +SettleCode=SettleCode +UserId=UserId +ClientCode=ClientCode +Direction=Direction +DealState=DealState +ClassName=ClassName +SecName=SecName +SecCode=SecCode +ActivationDate=ActivationDate +ActivationTime=ActivationTime +QuoteNo=QuoteNo +Benchmark=Benchmark + +[NegReports] +ReportNum=ReportNum +ClassCode=ClassCode +TradeDate=TradeDate +Value=Value +Qty=Qty +ReportDateTime=ReportDateTime +WithdrawTime=WithdrawTime +Account=Account +CPAccount=CPAccount +CPFirmId=CPFirmId +CPFirmName=CPFirmName +FirmId=FirmId +FirmName=FirmName +UserId=UserId +Direction=Direction +ReportState=ReportState +SecName=SecName +SecCode=SecCode +ClassName=ClassName +Commission=Commission +ReportKind=ReportKind + +[AccountPositions] +CurrCode=CurrCode +Description=Description +FirmId=FirmId +Tag=Tag +LoadDate=LoadDate +CurrentPos=CurrentPos +OpenBal=OpenBal +PlannedPos=PlannedPos +OrderBuy=OrderBuy +OrderSell=OrderSell +Netto=Netto +PlannedBal=PlannedBal + +[FirmDepoAccount] +SecCode=SecCode +FirmId=FirmId +LoadDate=LoadDate +CurrentPos=CurrentPos +OpenBal=OpenBal +PlannedPosBuy=PlannedPosBuy +PlannedPosSell=PlannedPosSell +SecName=SecName +UsQtyB=UsQtyB +UsQtyS=UsQtyS + +[SecDepoAccount] +SecCode=SecCode +FirmId=FirmId +DepAccId=DepAccId +TrdAccId=TrdAccId +LoadDate=LoadDate +CurrentPos=CurrentPos +OpenBal=OpenBal +PlannedPosBuy=PlannedPosBuy +PlannedPosSell=PlannedPosSell +SecName=SecName +PlanBal=PlanBal +UsQtyB=UsQtyB +UsQtyS=UsQtyS +Planned=Planned + +[StopOrders] +StopOrderNum=StopOrderNum +TradeDate=TradeDate +ConditionPrice=ConditionPrice +LinkedOrder=LinkedOrder +Price=Price +AllTradeNum=AllTradeNum +CoOrderNum=CoOrderNum +BaseOrderNum=BaseOrderNum +CoOrderPrice=CoOrderPrice +Offset=Offset +Spread=Spread +Qty=Qty +ClassCode=ClassCode +Balance=Balance +Condition=Condition +Expiry=Expiry +FilledQty=FilledQty +StopOrderDateTime=StopOrderDateTime +WithdrawDateTime=WithdrawDateTime +UID=UID +Account=Account +BrokerRef=BrokerRef +ClientCode=ClientCode +FirmId=FirmId +FirmName=FirmName +State=State +StateDesc=StateDesc +OrderKind=OrderKind +Type=Type +Mode=Mode +Operation=Operation +ConditionSecCode=ConditionSecCode +ConditionSecName=ConditionSecName +ConditionClassCode=ConditionClassCode +ConditionClassName=ConditionClassName +TransId=TransId +OffsetUnits=OffsetUnits +SpreadUnits=SpreadUnits +ActiveToTime=ActiveToTime +ConditionPrice2=ConditionPrice2 +ActiveFromToTime=ActiveFromToTime +MarketStopLimit=MarketStopLimit +MarketTakeProfit=MarketTakeProfit +ActiveFromTime=ActiveFromTime + +[Quotes] +QuoteNum=QuoteNum +TradeDate=TradeDate +ClassCode=ClassCode +SecCode=SecCode +Price=Price +Price2=Price2 +RepoRate=RepoRate +RepoValue=RepoValue +Repo2Value=Repo2Value +OriginRepoValue=OriginRepoValue +Discount=Discount +OriginDiscount=OriginDiscount +QuoteTime=QuoteTime +Qty=Qty +OriginQty=OriginQty +Operation=Operation +State=State +RepoEntry=RepoEntry +RepoTerm=RepoTerm +SettleCode=SettleCode +UserId=UserId +FirmId=FirmId +FirmName=FirmName + +[Trdaccs] +TrdAccId=TrdAccId +Type=Type +FirmId=FirmId +LoadDate=LoadDate +Description=Description +BankAccId=BankAccId +Status=Status +DepAccId=DepAccId +TrdAccType=TrdAccType +MainTrdAccId=MainTrdAccId +FirmUse=FirmUse +FullCoveredSell=FullCoveredSell +BankIdT0=BankIdT0 +BankIdTPlus=BankIdTPlus +DepUnitId=DepUnitId + +[CCPHold] +ClassCode=ClassCode +FirmId=FirmId +SecCode=SecCode +DepAccId=DepAccId +Account=Account +SettleDate=SettleDate +Qty=Qty +BankAccId=BankAccId +QtyBuy=QtyBuy +QtySell=QtySell +Netto=Netto +Debit=Debit +Credit=Credit +LoadDate=LoadDate +PlannedCovered=PlannedCovered +PlannedCoveredSellLimit=PlannedCoveredSellLimit + +[CCPIndicative] +ClassCode=ClassCode +SettleDate=SettleDate +SecCode=SecCode +CCPNum=CCPNum +LoadDate=LoadDate +QtyBegin=QtyBegin +QtyEnd=QtyEnd +Discount=Discount +RepoLRateRub=RepoLRateRub +RepoCRateRub=RepoCRateRub +RepoHRateRub=RepoHRateRub +RepoLRate=RepoLRate +RepoCRate=RepoCRate +RepoHRate=RepoHRate +QtyBeginRub=QtyBeginRub +QtyEndRub=QtyEndRub +SettlePrice=SettlePrice +PurePrice=PurePrice +Discount_L=Discount_L +Discount_H=Discount_H +ChangeTime=ChangeTime +LimitBegin=LimitBegin +LimitEnd=LimitEnd +BasePoint=BasePoint + +[CCPPriceRange] +ClassCode=ClassCode +SecCode=SecCode +CCPNum=CCPNum +LoadDate=LoadDate +QtyBegin=QtyBegin +QtyEnd=QtyEnd +Discount=Discount +RTLRub=RTLRub +RTHRub=RTHRub +QtyBeginRub=QtyBeginRub +QtyEndRub=QtyEndRub +Discount_CCP=Discount_CCP +Discount_L=Discount_L +Discount_H=Discount_H +SettlePrice=SettlePrice +ChangeTime=ChangeTime +CurrCode=CurrCode + +[MarketMakerStats] +FirmId=FirmId +ClassCode=ClassCode +SecCode=SecCode +LoadDate=LoadDate +MGFBUYBREACHED=MGFBUYBREACHED +MGFSELLBREACHED=MGFSELLBREACHED +TOTALBUY=TOTALBUY +TOTALSELL=TOTALSELL +BUYMGF=BUYMGF +SELLMGF=SELLMGF +MINBUY=MINBUY +MAXSELL=MAXSELL +ALLOWEDBUY=ALLOWEDBUY +ALLOWEDSELL=ALLOWEDSELL +SPREAD=SPREAD +SPREADLIMIT=SPREADLIMIT +TOTALBREACHTIME=TOTALBREACHTIME +TOTALKEEPTIME=TOTALKEEPTIME +KEEPFLAG=KEEPFLAG +VOLTRADED=VOLTRADED +FAIRVOLUME=FAIRVOLUME +ALLOWDBREACHTIME=ALLOWDBREACHTIME +QUOTETYPE=QUOTETYPE +MMTYPE=MMTYPE +UpdateTime=UpdateTime +FACTKEEPPERCENT=FACTKEEPPERCENT +LEFTKEEPTIME=LEFTKEEPTIME +MMUPDATETIME=MMUPDATETIME + +[MarketMakerForts] +LoadDate=LoadDate +DataID=DataID +FirmID=FirmID +TrdAccID=TrdAccID +ClassCode=ClassCode +SecCode=SecCode +MM_SPREAD=MM_SPREAD +MM_AMOUNT=MM_AMOUNT +SPREAD=SPREAD +PERIOD_START_DATE=PERIOD_START_DATE +PERIOD_START_TIME=PERIOD_START_TIME +PERIOD_END_DATE=PERIOD_END_DATE +PERIOD_END_TIME=PERIOD_END_TIME +SPREAD_SIGN=SPREAD_SIGN +AMOUNT_SIGN=AMOUNT_SIGN +PRICE_EDGE_SELL=PRICE_EDGE_SELL +PRICE_EDGE_BUY=PRICE_EDGE_BUY +AMOUNT_SELLS=AMOUNT_SELLS +AMOUNT_BUYS=AMOUNT_BUYS +PERCENT_TIME=PERCENT_TIME +CSTRIKE_OFFSET=CSTRIKE_OFFSET +FULFIL_MIN=FULFIL_MIN +FULFIL_PARTIAL=FULFIL_PARTIAL +FULFIL_TOTAL=FULFIL_TOTAL +IS_FULFIL_MIN=IS_FULFIL_MIN +IS_FULFIL_PARTIAL=IS_FULFIL_PARTIAL +IS_FULFIL_TOTAL=IS_FULFIL_TOTAL + +[MarketMakerExtStats] +ClassCode=ClassCode +SecCode=SecCode +MMID=MMID +PARENTMMID=PARENTMMID +TRDACCID=TRDACCID +CLIENTCODE=CLIENTCODE +SCHEMANAME=SCHEMANAME +AGREEMENT=AGREEMENT +AGREEMENTDATE=AGREEMENTDATE +MINSPREADVOLUMEBUY=MINSPREADVOLUMEBUY +MINSPREADVALUEBUY=MINSPREADVALUEBUY +MINSPREADVOLUMESELL=MINSPREADVOLUMESELL +MINSPREADVALUESELL=MINSPREADVALUESELL +MAXTRADESVOLUME=MAXTRADESVOLUME +MAXTRADESVALUE=MAXTRADESVALUE +MINPASSIVETRADESVALUE=MINPASSIVETRADESVALUE +MAXSPREADPERC=MAXSPREADPERC +MAXSPREADPERC1=MAXSPREADPERC1 +MAXSPREADPERC2=MAXSPREADPERC2 +MAXSPREADPRICE=MAXSPREADPRICE +MINKEEPTIME=MINKEEPTIME +MINKEEPPERCENT=MINKEEPPERCENT +CURRENTSPREADPERC=CURRENTSPREADPERC +CURRENTSPREADPERC1=CURRENTSPREADPERC1 +CURRENTSPREADPERC2=CURRENTSPREADPERC2 +CURRENTSPREADPRICE=CURRENTSPREADPRICE +CURRENTMINBUYPRICE=CURRENTMINBUYPRICE +CURRENTMAXSELLPRICE=CURRENTMAXSELLPRICE +CURRENTALLOWEDBUYPRICE=CURRENTALLOWEDBUYPRICE +CURRENTALLOWEDSELLPRICE=CURRENTALLOWEDSELLPRICE +TOTALBUYVOLUME=TOTALBUYVOLUME +TOTALSELLVOLUME=TOTALSELLVOLUME +TOTALBUYVALUE=TOTALBUYVALUE +TOTALSELLVALUE=TOTALSELLVALUE +PUBLICPRICE=PUBLICPRICE +TOTALBREACHTIME=TOTALBREACHTIME +TOTALKEEPTIME=TOTALKEEPTIME +TOTALKEEPTIME1=TOTALKEEPTIME1 +TOTALKEEPTIME2=TOTALKEEPTIME2 +FACTTRADESVOLUME=FACTTRADESVOLUME +FACTTRADESVALUE=FACTTRADESVALUE +FACTPASSIVETRADESVOLUME=FACTPASSIVETRADESVOLUME +FACTPASSIVETRADESVALUE=FACTPASSIVETRADESVALUE +QUOTETYPE=QUOTETYPE +FACTKEEPPERCENT=FACTKEEPPERCENT +FACTKEEPPERCENT1=FACTKEEPPERCENT1 +FACTKEEPPERCENT2=FACTKEEPPERCENT2 +LEFTKEEPTIME=LEFTKEEPTIME +UPDATETIME=UPDATETIME +PROCESSED=PROCESSED +KEEPSPREADVARIANT=KEEPSPREADVARIANT + +[RmHold] +ClassCode=ClassCode +FirmId=FirmId +SecCode=SecCode +SettleDate=SettleDate +LoadDate=LoadDate +Account=Account +BankAccId=BankAccId +Debit=Debit +Credit=Credit +ValueBuy=ValueBuy +ValueSell=ValueSell +MarginCall=MarginCall +PlannedCovered=PlannedCovered +DebitBalance=DebitBalance +CreditBalance=CreditBalance + +[RmIndicative] +ClassCode=ClassCode +SecCode=SecCode +SettleDate=SettleDate +LoadDate=LoadDate +Num=Num +LimitBegin=LimitBegin +LimitEnd=LimitEnd +LRateRub=LRateRub +CRateRub=CRateRub +HRateRub=HRateRub +LimitBeginRub=LimitBeginRub +LimitEndRub=LimitEndRub +ChangeTime=ChangeTime + +[RmPriceRange] +ClassCode=ClassCode +SecCode=SecCode +LoadDate=LoadDate +Num=Num +LimitBegin=LimitBegin +LimitEnd=LimitEnd +RTLRub=RTLRub +RTHRub=RTHRub +LimitBeginRub=LimitBeginRub +LimitEndRub=LimitEndRub +SETTLEPRICE=SETTLEPRICE +CHANGETIME=CHANGETIME +Discount_L=Discount_L +Discount_H=Discount_H + +[RmPriceRangeFirm] +ClassCode=ClassCode +SecCode=SecCode +LoadDate=LoadDate +FirmId=FirmId +Account=Account +BankAccId=BankAccId +K_EXCH=K_EXCH +K_EXCH_SET=K_EXCH_SET +K_USER=K_USER +K_USER_SET=K_USER_SET +COLLATERAL=COLLATERAL + +[SecAccIntEx] +LoadDate=LoadDate +ClassCode=ClassCode +SecCode=SecCode +CurrCode=CurrCode +AccInt=AccInt +FaceValue=FaceValue + +[RmProductSpreadCurr] +ClassCode=ClassCode +SecCode=SecCode +SecCode2=SecCode2 +LoadDate=LoadDate +FirmId=FirmId +Coef=Coef + +[AlgoOrders] +ClassCode=ClassCode +OrderNum=OrderNum +TradeDate=TradeDate +order_date=order_date +order_time=order_time +order_time_mcs=order_time_mcs +kill_date=kill_date +kill_time=kill_time +kill_time_mcs=kill_time_mcs +class_code=class_code +class_name=class_name +sec_code=sec_code +sec_name=sec_name +sec_fullname=sec_fullname +buysell=buysell +mktlimit=mktlimit +account=account +algo_firm_code=algo_firm_code +firmid=firmid +qty=qty +balance=balance +qty_exec=qty_exec +value_exec=value_exec +exec_percent=exec_percent +price=price +value=value +vwap=vwap +vwap_price=vwap_price +uid=uid +client=client +memo=memo +status=status +cancel_reason=cancel_reason +settle_code=settle_code +time_in_force=time_in_force +expiry_date=expiry_date +expiry_time=expiry_time +last_trade_price=last_trade_price +cur_session=cur_session +enable_exec_alert=enable_exec_alert +use_work_interval=use_work_interval +start_time=start_time +stop_time=stop_time +cp_firmid=cp_firmid +displayedqty=displayedqty +cancel_flag=cancel_flag +aggressive=aggressive +spread=spread +qty_balance=qty_balance +value_balance=value_balance +qty_value=qty_value +currency=currency +class_code_buy=class_code_buy +class_name_buy=class_name_buy +sec_code_buy=sec_code_buy +sec_name_buy=sec_name_buy +sec_fullname_buy=sec_fullname_buy +account_buy=account_buy +firmid_buy=firmid_buy +client_buy=client_buy +class_code_sell=class_code_sell +class_name_sell=class_name_sell +sec_code_sell=sec_code_sell +sec_name_sell=sec_name_sell +sec_fullname_sell=sec_fullname_sell +account_sell=account_sell +firmid_sell=firmid_sell +client_sell=client_sell +spread_type=spread_type +iter_qty=iter_qty +iter_value=iter_value +value_balance_buy=value_balance_buy +qty_exec_buy=qty_exec_buy +value_exec_buy=value_exec_buy +vwap_buy=vwap_buy +vwap_price_buy=vwap_price_buy +value_balance_sell=value_balance_sell +qty_exec_sell=qty_exec_sell +value_exec_sell=value_exec_sell +vwap_sell=vwap_sell +vwap_price_sell=vwap_price_sell +iter_time=iter_time +stop_limit=stop_limit +take_profit=take_profit +spread_percent=spread_percent +stop_limit_percent=stop_limit_percent +take_profit_percent=take_profit_percent +limit_kind=limit_kind +cancel_client_orders=cancel_client_orders +stop_limit_type=stop_limit_type +take_profit_type=take_profit_type +limit_type=limit_type +limit=limit +limit_money=limit_money +iter_numb_set_mode=iter_numb_set_mode +number=number +current=current +time_type=time_type +use_time_profile=use_time_profile +volatility=volatility +volatility_var=volatility_var +volatility_var_type=volatility_var_type +need_market_percent=need_market_percent +value_percent=value_percent +vwap_limit=vwap_limit +vwap_limit_cur=vwap_limit_cur +no_stop_time=no_stop_time +value_market=value_market +qty_market=qty_market +vwap_market=vwap_market +min_price=min_price +max_price=max_price +enable_catch_up=enable_catch_up +catch_up_percent=catch_up_percent +tick_up=tick_up +use_ioc=use_ioc +use_iter_limit=use_iter_limit +work_in_price_range=work_in_price_range +order_state=order_state +include_own_trades=include_own_trades + +[AlgoLinkedOrders] +OrderNum=OrderNum +ClassCode=ClassCode +TradeDate=TradeDate +LinkedOrderNum=LinkedOrderNum +LinkedOrderClassCode=LinkedOrderClassCode +OrderState=OrderState + +[AlgoLinkedTrades] +OrderNum=OrderNum +ClassCode=ClassCode +TradeDate=TradeDate +LinkedTradeNum=LinkedTradeNum +LinkedTradeClassCode=LinkedTradeClassCode +LinkedTradeSide=LinkedTradeSide + +[SecuritiesDictionary] +ClassCode=ClassCode +SecCode=SecCode +TradeDate=TradeDate +ISIN=ISIN +StockCode=StockCode +RIC=RIC +CUSIP=CUSIP +SEDOL=SEDOL +RegNumber=RegNumber +LocalCode=LocalCode +StockName=StockName +SecShortName=SecShortName +FaceUnit=FaceUnit +SecFullName=SecFullName +BSID=BSID + +[TransResult] +TransNum=TransNum +TradeDate=TradeDate +TransDateTime=TransDateTime +BaseTransNum=BaseTransNum +RequestId=RequestId +TransType=TransType +ClassCode=ClassCode +SecurityCode=SecurityCode +Status=Status +FirmID=FirmID +FirmName=FirmName +Account=Account +ClientCode=ClientCode +ResultMsg=ResultMsg +UID=UID +BrokerRef=BrokerRef +ExchangeCode=ExchangeCode +Price=Price +OrderNum=OrderNum +Qty=Qty +Balance=Balance +Flags=Flags +GateReplyTime=GateReplyTime +ErrorCode=ErrorCode +ErrorSource=ErrorSource +FirstOrderNum=FirstOrderNum +AppSentTime=AppSentTime +AppSentTimeMs=AppSentTimeMs +ExporterGotTime=ExporterGotTime +ExporterGotTimeMs=ExporterGotTimeMs +Order1SecCode=Order1SecCode +Order1FirstNum=Order1FirstNum +Order1Num=Order1Num +Order1Price=Order1Price +Order1Qty=Order1Qty +Order1Balance=Order1Balance +Order1FirmId=Order1FirmId +Order1Account=Order1Account +Order1ClientCode=Order1ClientCode +Order1Brokerref=Order1Brokerref +Order1ExchangeCode=Order1ExchangeCode +Order1Flags=Order1Flags +Order2SecCode=Order2SecCode +Order2FirstNum=Order2FirstNum +Order2Num=Order2Num +Order2Price=Order2Price +Order2Qty=Order2Qty +Order2Balance=Order2Balance +Order2FirmId=Order2FirmId +Order2Account=Order2Account +Order2ClientCode=Order2ClientCode +Order2Brokerref=Order2Brokerref +Order2ExchangeCode=Order2ExchangeCode +Order2Flags=Order2Flags + + + + +[SettleCodes] +ClassCode=ClassCode +SettleCode=SettleCode +FirmID=FirmID +TradeDate=TradeDate +Description=Description +SettleDate=SettleDate + +[NCCTransfers] +TradeDate=TradeDate +ClassCode=ClassCode +TradeNum=TradeNum +OrderNum=OrderNum +SecCode=SecCode +FirmID=FirmID +CPFirmId=CPFirmId +TradeDateTime=TradeDateTime +Sell=Sell +BrokerRef=BrokerRef +Account=Account +Price=Price +Qty=Qty +Value=Value +SettleCurrency=SettleCurrency +Accruedint=Accruedint +SettleCode=SettleCode +SettleDate=SettleDate +Kind=Kind +SystemRef=SystemRef +BankAccId=BankAccId +ClearingFirmId=ClearingFirmId +ClearingBankAccId=ClearingBankAccId +TranType=TranType +TranInfo=TranInfo +Status=Status + +[Prohibitions] +TradeDate=TradeDate +FirmID=FirmID +ContractType=ContractType +RuleNumber=RuleNumber +RuleType=RuleType +SecCode=SecCode +BaseSecCode=BaseSecCode +Section=Section +TrdAccId=TrdAccId +Priority=Priority +ProhibitionType=ProhibitionType +Status=Status +Initiator=Initiator +InitiatorType=InitiatorType + +[CurrencyInfo] +TradeDate=TradeDate +CurrCode=CurrCode +CurrShortName=CurrShortName +CurrFullName=CurrFullName +Unicode=Unicode +HtmlCode=HtmlCode +QtyScale=QtyScale +MainCurrencyFlag=MainCurrencyFlag + +[Calendars] +TradeDate=TradeDate +CalendarDate=CalendarDate +Priority=Priority +CurrCode=CurrCode +ClassCode=ClassCode +SecCode=SecCode +SecType=SecType +DateType=DateType diff --git a/internal/lkgateway/web/quikexport/exporter_pg.sql b/internal/lkgateway/web/quikexport/exporter_pg.sql new file mode 100644 index 0000000..9cc2d17 --- /dev/null +++ b/internal/lkgateway/web/quikexport/exporter_pg.sql @@ -0,0 +1,2429 @@ +-- Классы инструментов +CREATE TABLE "Classes" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + + "ClassName" varchar(128) NULL, -- Наименование класса инструментов + "FirmsId" varchar(12) NULL, -- Идентификатор участника торгов + "RFlags" int NULL, -- Флаги пользовательских прав + "ClassType" smallint NULL, -- Тип класса + "ClassSubType" smallint NULL, -- Подтип класса + "CommonBehavior" decimal(19, 0) NULL, -- Битовое поле с набором общих для всех классов + "SpecificBehavior" decimal(19, 0) NULL, -- Битовое поле с набором специфических для указанного типа класса флагов + + PRIMARY KEY ("TradeDate", "ClassCode") +); + + +-- Инструменты +CREATE TABLE "Securities" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + "SecCode" varchar(12) NOT NULL, -- Код инструмента + + "FaceValue" decimal(19, 8) NULL, -- Номинал + "OptionStrike" decimal(19, 8) NULL, -- Страйк опциона + "OptionType" varchar(12) NULL, -- Тип опциона + "OptionBaseSecCode" varchar(12) NULL, -- Код базового актива для срочного контракта + "OptionBaseClassCode" varchar(12) NULL, -- Код класса базового актива для срочного контракта + "LotSize" int NULL, -- Размер лота + "MatDate" timestamp without time zone NULL, -- Дата погашения + "DaysToMatDate" int NULL, -- Количество дней до погашения + "MinStep" decimal(19, 8) NULL, -- Шаг цены + "Scale" int NULL, -- Точность (количество значащих цифр после запятой) + "FaceUnit" varchar(12) NULL, -- Валюта номинала + "SecShortName" varchar(32) NULL, -- Краткое наименование инструмента + "SecFullName" varchar(128) NULL, -- Полное наименование инструмента + "SecName" varchar(128) NULL, -- Полное наименование инструмента и наименование класса инструментов в формате «<Наименование инструмента><[Наименование класса инструментов]>» + "ISINCode" varchar(12) NULL, -- ISIN + "RegNumber" varchar(30) NULL, -- Номер гос. регистрации выпуска ценных бумаг + "SecMarginType" varchar(4) NULL, -- Принадлежность инструмента к списку маржинальных бумаг и списку инструментов, принимаемых в обеспечение маржинального кредита + "CouponValue" decimal(19, 6) NULL, -- Размер купона + "SecAccruedint" decimal(28, 8) NULL, -- Процентный доход, выраженный в рублях + "YieldAtPrevWAPrice" decimal(19, 4) NULL, -- Доходность по предыдущей оценке + "BuyBackPrice" decimal(19, 8) NULL, -- Цена оферты + "NextCoupon" timestamp without time zone NULL, -- Дата выплаты купона + "BuyBackDate" timestamp without time zone NULL, -- Дата оферты + "BuyMatDate" timestamp without time zone NULL, -- Дата расчетов сделки на покупку + "SellMatDate" timestamp without time zone NULL, -- Дата расчетов сделки на продажу + "SecType" smallint NULL, -- Тип инструмента + "SecSubType" smallint NULL, -- Подтип инструмента. + "ListLevel" smallint NULL, -- Уровень листинга + "CouponPeriod" smallint NULL, -- Длительность купона + "SecTradeCurrency" varchar(12) NULL, -- Сопряженная валюта + "StepPriceCurrency" varchar(12) NULL, -- Валюта шага цены + "FirstCurrCode" varchar(12) NULL, -- Код котируемой валюты в паре + "SecondCurrCode" varchar(12) NULL, -- Код базовой валюты в паре + "BuyLegSecCode" varchar(12) NULL, -- Код бумаги инструмента ноги на покупку + "BuyLegClassCode" varchar(12) NULL, -- Код класса инструмента ноги на покупку + "SellLegSecCode" varchar(12) NULL, -- Код бумаги инструмента ноги на продажу + "SellLegClassCode" varchar(12) NULL, -- Код класса инструмента ноги на продажу + "CFICode" varchar(7) NULL, -- CFI-код + "StockCode" varchar(12) NULL, -- Тикер инструмента + "SEDOLCode" varchar(7) NULL, -- Идентификатор инструмента из Stock Exchange Daily Official List (SEDOL) + "RICCode" varchar(32) NULL, -- Reuters Instrument Code + "CUSIPCode" varchar(9) NULL, -- CUSIP Code (идентификатор для северо-американских финансовых инструментов) + "StockName" varchar(32) NULL, -- Код деривативного контракта в формате QUIK + "BSID" varchar(20) NULL, -- Bloomberg security identifier + "QtyScale" int NULL, -- Точность количества (масштабный фактор, т.е. реальное кол-во = кол-во/(10^qty_scale)) + "QtyMultiplier" decimal(19, 0) NULL, -- Кратность (квант) количества + "FirstCurrQtyScale" int NULL, -- Точность количества базовой валюты + "SecondCurrQtyScale" int NULL, -- Точность количества котируемой валюты + "SpecificFlags" int NULL, -- Специфические параметры инструмента + "SettleCode" varchar(12) NULL, -- Код расчетов по умолчанию + "SettleDate" timestamp without time zone NULL, -- Дата расчетов по умолчанию + "TickSizeTableId" varchar(64) NULL, -- Идентификатор таблицы с шагами цены, используемый по умолчанию + "ExpDate" timestamp without time zone NULL, -- День/месяц/год экспирации срочного инструмента + + "Leg1ClassCode" varchar(12) NULL, -- Код класса инструмента первой ноги + "Leg1SecCode" varchar(12) NULL, -- Код инструмента первой ноги + "Leg1CFI" varchar(7) NULL, -- CFI-код инструмента первой ноги + "Leg1RatioQty" decimal(19, 0) NULL, -- Кол-во, используемое для образования позиции при выполнении операции по первой ноге + "Leg1Side" char(1) NULL, -- Направление сделки по первой ноге + "Leg1SettleDate" timestamp without time zone NULL, -- Дата расчетов по первой ноге + + "Leg2ClassCode" varchar(12) NULL, -- Код класса инструмента второй ноги + "Leg2SecCode" varchar(12) NULL, -- Код инструмента второй ноги + "Leg2CFI" varchar(7) NULL, -- CFI-код инструмента второй ноги + "Leg2RatioQty" decimal(19, 0) NULL, -- Кол-во, используемое для образования позиции при выполнении операции по второй ноге + "Leg2Side" char (1) NULL, -- Направление сделки по второй ноге + "Leg2SettleDate" timestamp without time zone NULL, -- Дата расчетов по второй ноге + + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + + PRIMARY KEY ("TradeDate", "ClassCode", "SecCode") +); + + +-- Таблица сделок +CREATE TABLE "Trades" +( + "TradeNum" decimal(20, 0) NOT NULL, -- Номер сделки + "Operation" varchar(40) NOT NULL, -- Направленность сделки (BUY или SELL) + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии в момент экспорта + + "SecCode" varchar(12) NULL, -- Код инструмента + "UID" int NULL, -- Идентификатор пользователя QUIK + "Kind" varchar(128) NULL, -- Вид сделки + "Accruedint" decimal(19, 8) NULL, -- Накопленный купонный доход + "Accruedint2" decimal(19, 8) NULL, -- Доход(%) на дату выкупа + "LowerDiscount" decimal(19, 8) NULL, -- Нижний дисконт(%) + "OrderNum" decimal(20, 0) NULL, -- Номер заявки + "Price" decimal(19, 8) NULL, -- Цена сделки + "Price2" decimal(19, 8) NULL, -- Цена выкупа второй части РЕПО + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО (%) + "RepoValue" decimal(28, 8) NULL, -- Сумма РЕПО + "Repo2Value" decimal(28, 8) NULL, -- Объем сделки выкупа РЕПО, рублей + "StartDiscount" decimal(19, 8) NULL, -- Начальный дисконт(%) + "TSCommission" decimal(19, 8) NULL, -- Комиссия торговой системы + "TsCommissionCurrency" varchar(12) NULL, -- Валюта комиссии торговой системы + "BrokerCommissionCurrency" varchar(12) NULL, -- Валюта комиссии брокера + "TradingSession" int NULL, -- Идентификатор торговой сесии + "StartDate" timestamp without time zone NULL, -- Дата, начиная с которой допускается реализация права на совершение валютирования. Заполняется только для FLEX FORWARD. + "FixingDate" timestamp without time zone NULL, -- Дата фиксации котировки для расчетов по контракту типа NDF + "OperationType" int NULL, -- Тип контракта + "SpotRate" decimal(19, 8) NULL, -- Цена котировка по спот-инструменту в момент совершения сделки + "UpperDiscount" decimal(19, 8) NULL, -- Верхний дисконт(%) + "Value" decimal(28, 8) NULL, -- Объем сделки без учета комиссионного сбора биржи и % дохода + "Yield" decimal(19, 4) NULL, -- Доходность + "Qty" decimal(28, 8) NULL, -- Количество бумаг в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг в штуках + "TradeDateTime" timestamp without time zone NULL, -- Дата и время сделки + "TradeTimeMs" int NULL, -- Микросекунды времени сделки + "BlockSecurities" int NULL, -- Блокировка обеспечения + "RepoTerm" int NULL, -- Срок РЕПО + "ClearingCommission" decimal(19, 8) NULL, -- Клиринговая комиссия. Параметр сделок на МБ + "ExchangeCommission" decimal(19, 8) NULL, -- Комиссия Фондовой биржи. Параметр сделок на МБ + "TechCenterCommission" decimal(19, 8) NULL, -- Комиссия Технического центра. Параметр сделок на МБ + "Account" varchar(12) NULL, -- Торговый счет + "BrokerRef" varchar(34) NULL, -- Комментарий, обычно: <код клиента>/<номер поручения> + "ClientCode" varchar(12) NULL, -- Код клиента (содержится также в виде подстроки в параметре BrokerRef) + "SettleCode" varchar(12) NULL, -- Код расчетов по сделке в режимах РПС, РЕПО + "UserId" varchar(32) NULL, -- Идентификатор трейдера + "ExchangeCode" varchar(64) NULL, -- Идентификатор биржи + "Type" varchar(30) NULL, -- Тип сделки + "FirmId" varchar(12) NULL, -- Дилер + "FirmName" varchar(128) NULL, -- Организация трейдера + "CPFirmId" varchar(12) NULL, -- Партнер (для режимов РПС, РЕПО) + "CPFirmName" varchar(128) NULL, -- Организация партнера (для режимов РПС, РЕПО) + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов + "PriceCurrency" varchar(12) NULL, -- Валюта сделки + "StationId" varchar(32) NULL, -- Идентификатор рабочей станции + "SettleDate" timestamp without time zone NULL, -- Дата расчетов + "Period" int NULL, -- Период торговой сессии + "Sell" int NULL, -- Направление заявки + "RFlags" int NULL, -- Набор битовых флагов + "BankAccId" varchar(12) NULL, -- Идентификатор расчетного счета/кода в клиринговой организации + "LinkedTrade" decimal(20, 0) NULL, -- Номер витринной сделки в торговой системе для сделок РЕПО с ЦК и SWAP + "CanceledUserId" decimal(10, 0) NULL, -- Идентификатор пользователя, отказавшегося от сделки + "CanceledDateTime" timestamp without time zone NULL, -- Дата и время отказа от сделки с точностью до секунд + "CanceledDateTimeMs" int NULL, -- Микросекунды от времени отказа от сделки + "TransId" decimal(20, 0) NULL, -- Идентификатор транзакции + "ClearingFirmId" varchar(12) NULL, -- Идентификатор фирмы клиринга + "ClearingBankAccId" varchar(12) NULL, -- Идентификатор клирингового счета + "LSecCode" varchar(12) NULL, -- Приоритетное обеспечение + "SystemRef" varchar(12) NULL, -- Системная ссылка. Дополнительная информация, передаваемая торговой системой + "BrokerCommission" decimal(19, 8) NULL, -- Комиссия брокера + "OrderExchangeCode" varchar(64) NULL, -- биржевой номер заявки, по которой исполнилась сделка + "ExecMarket" varchar(4) NULL, -- Идентификатор биржевой площадки, на которой зарегистрирована сделка + "LiquidityIndicator" int NULL, -- Индикатор ликвидности сделки + "Extref" varchar(32) NULL, -- Внешняя ссылка, используется для обратной связи с внешними системами + "ExtTradeFlags" int NULL, -- Расширенные флаги + "OnBehalfOfUID" decimal(10, 0) NULL, -- UID пользователя, от имени которого была совершена сделка + "ClientQualifier" int NULL, -- Квалификатор клиента, от имени которого была совершена сделка + "ClientShortCode" decimal(19, 0) NULL, -- Квалификатор клиента, от имени которого была совершена сделка. Описание такое же, как в аналогичном поле в заявках + "InvestmentDecisionMakerQualifier" int NULL, -- Квалификатор принявшего решение о совершении сделки + "InvestmentDecisionMakerShortCode" decimal(19, 0) NULL, -- Краткий идентификатор принявшего решение о совершении сделки + "ExecutingTraderQualifier" int NULL, -- Квалификатор исполнившего заявку, по которой была совершена сделка + "ExecutingTraderShortCode" decimal(19, 0) NULL, -- Краткий идентификатор исполнившего заявку, по которой была совершена сделка + "WaiverFlag" int NULL, -- Признак того, что транзакция была совершена по правилам пре-трейда + "MLegBaseClassCode" varchar(12) NULL, -- Код класса базового инструмента для мультилеговых инструментов + "MLegBaseSecCode" varchar(12) NULL, -- Код базового инструмента для мультилеговых инструментов + "SideQualifier" int NULL, -- Квалификатор операции + "OtcPostTradeIndicator" int NULL, -- OTC post-trade индикатор + "Capacity" int NULL, -- Роль в исполнении заявки, по которой прошла сделка. Описание такое же, как в аналогичном поле в заявках + "CrossRate" decimal(19, 8) NULL, -- Кросс-курс валюты цены сделки к валюте расчетов + "CPUserId" varchar(32) NULL, -- Идентификатор партнера + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + "FirstCurrCode" varchar(12) NULL, -- Базовая валюта + "FirstCurrQty" decimal(28, 8) NULL, -- Количество базовой валюты + "SecondCurrCode" varchar(12) NULL, -- Котируемая валюта + "SecondCurrQty" decimal(28, 8) NULL, -- Количество котируемой валюты + "DepositIntent" int NULL, -- Тип сделки депозита + "OpenRepo2Value" decimal(28, 8) NULL, -- Стоимость выкупа РЕПО с открытой датой + "OpenRepo2Date" timestamp without time zone NULL, -- День Т+1 для сделок РЕПО с открытой датой + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + "OpenRepoEverGreenTerm" int NULL, -- Период до исполнения второй части сделки РЕПО с открытой датой + "ZSpread" decimal(19, 2) NULL, -- Z-спред по цене заявки / сделки / котировки + "PercentPeriod" smallint NULL, -- Период процентных выплат + + PRIMARY KEY ("TradeNum", "Operation", "ClassCode", "TradeDate") +); + + +-- Таблица обезличенных сделок +CREATE TABLE "AllTrades" +( + "TradeNum" decimal(20, 0) NOT NULL, -- Номер сделки + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "SecCode" varchar(12) NOT NULL, -- Код инструмента + + "Accruedint" decimal(19, 8) NULL, -- Накопленный купонный доход + "Price" decimal(19, 8) NULL, -- Цена сделки, за одну бумагу + "Value" decimal(28, 8) NULL, -- Объем сделки без учета комиссионного сбора биржи и % дохода + "Yield" decimal(19, 4) NULL, -- Доходность + "RepoValue" decimal(19, 8) NULL, -- Сумма РЕПО + "Repo2Value" decimal(19, 8) NULL, -- Объем сделки выкупа РЕПО + "Qty" decimal(28, 8) NULL, -- Количество бумаг в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг в штуках + "AllTradeTimeMs" int NULL, -- Микросекунды времени регистрации сделки + "RepoTerm" int NULL, -- Срок РЕПО в днях + "SettleCode" varchar(12) NULL, -- Код расчетов по сделке + "Operation" varchar(40) NULL, -- Направленность операции: «Покупка» либо «Продажа» + "Period" int NULL, -- Период торговой сессии + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО (%) + "Flags" int NULL, -- Набор битовых флагов + "AllTradeDateTime" timestamp without time zone NULL, -- Дата и время регистрации сделки в торговой системе + "OpenInterest" decimal(28, 8) NULL, -- Открытый интерес + "ExchangeCode" varchar(64) NULL, -- Биржевой номер обезличенной сделки + "ExecMarket" varchar(4) NULL, -- Идентификатор торговой площадки + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + "SecName" varchar(128) NULL, -- Полное наименование инструмента + "ClassName" varchar(128) NULL, -- Наименование класса инструментов + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + "ZSpread" decimal(19, 2) NULL, -- Z-спред по цене заявки / сделки / котировки + + PRIMARY KEY ("TradeNum", "ClassCode", "TradeDate", "SecCode") +); + + +-- Таблица заявок +CREATE TABLE "Orders" +( + "OrderNum" decimal(20, 0) NOT NULL, -- Идентификационный номер + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "SecCode" varchar(12) NULL, -- Код инструмента + "Accruedint" decimal(19, 8) NULL, -- Накопленный купонный доход + "Price" decimal(19, 8) NULL, -- Цена за одну ценную бумагу + "Value" decimal(28, 8) NULL, -- Объем сделки без учета комиссионного сбора биржи и % дохода + "Yield" decimal(19, 4) NULL, -- Доходность + "Price2" decimal(19, 8) NULL, -- Цена второй части РЕПО + "Balance" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в лотах + "Balance_pcs" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в штуках + "OrderDateTime" timestamp without time zone NULL, -- Дата и время регистрации заявки в торговой системе + "OrderTimeMs" int NULL, -- Микросекунды времени регистрации заявки в торговой системе + "Qty" decimal(28, 8) NULL, -- Количество ценных бумаг в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество ценных бумаг в лотах + "UID" int NULL, -- Идентификатор пользователя QUIK + "WithdrawDateTime" timestamp without time zone NULL, -- Дата и время снятия заявки + "WithdrawTimeMs" int NULL, -- Микросекунды времени снятия заявки + "Account" varchar(12) NULL, -- Торговый счет + "BrokerRef" varchar(34) NULL , -- Комментарий + "ClientCode" varchar(12) NULL, -- Код клиента + "UserId" varchar(32) NULL, -- Идентификатор трейдера в торговой системе + "SettleCode" varchar(12) NULL, -- Код расчетов + "Operation" varchar(40) NULL, -- Тип операции (продажа или покупка) + "State" varchar(12) NULL, -- Состояние заявки + "Type" varchar(12) NULL, -- Тип заявки, код из трех букв + "FirmId" varchar(12) NULL, -- Код участника торгов + "FirmName" varchar(128) NULL, -- Наименование участника торгов + "ExchangeCode" varchar(64) NULL, -- Код биржи + "ActivationTime" timestamp without time zone NULL, -- Время активации заявки + "LinkedOrder" decimal(20, 0) NULL, -- Номер исходной заявки + "Expiry" varchar(20) NULL, -- Срок истечения заявки + "IsMarketMaker" varchar(8) NULL, -- Признак заявки маркетмейкера + "TransId" decimal(20, 0) NULL, -- Поле для связи между транзакцией и заявкой + "Visible" decimal(28, 8) NULL, -- Количество ценных бумаг + "Period" int NULL, -- Период торговой сессии + "Flags" int NULL, -- Набор битовых флагов + "BankAccId" varchar(12) NULL, -- Идентификатор расчетного счета/кода в клиринговой организации + "ValueEntryType" varchar(16) NULL, -- Способ указания размера заявки + "RepoTerm" int NULL, -- Срок РЕПО в календарных днях + "RepoValue" decimal(19, 8) NULL, -- Сумма РЕПО на текущую дату + "Repo2Value" decimal(19, 8) NULL, -- Объем сделки выкупа РЕПО + "RepoValueBalance" decimal(19, 8) NULL, -- Остаток суммы РЕПО + "StartDiscount" decimal(19, 8) NULL, -- Начальный дисконт в процентах + "RejectReason" varchar(128) NULL, -- Причина отклонения заявки брокером + "ExtOrderFlags" int NULL, -- Битовое поле для получения специфических параметров с западных площадок + "MinQty" decimal(28, 8) NULL, -- Минимально допустимое количество, которое может быть указано в заявке по данному инструменту + "ExecType" varchar(32) NULL, -- Тип исполнения заявки + "SideQualifier" smallint NULL, -- Поле для получения параметров по западным площадкам + "AcntType" smallint NULL, -- Поле для получения параметров по западным площадкам + "Capacity" smallint NULL, -- Роль в исполнении заявки + "PassiveOnlyOrder" smallint NULL, -- Поле для получения параметров по западным площадкам + "StopOrder" decimal(20, 0) NULL, -- Номер стоп-заявки, на основании которой порождена текущая заявка + "AwgPrice" decimal(19, 8) NULL, -- Средняя цена приобретения + "CanceledUserId" decimal(10, 0) NULL, -- Идентификатор пользователя, снявшего заявку + "ExpiryTime" timestamp without time zone NULL, -- Время окончания срока действия заявки + "PriceCurrency" varchar(12) NULL, -- Валюта, в которой указана цена заявки + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов по данной заявке + "AcceptedUID" decimal(10, 0) NULL, -- Режим работы с подтверждением, UID пользователя, подтвердившего заявку с подтверждением + "ExtOrderStatus" int NULL, -- Расширенный статус заявки + "FilledValue" decimal(19, 8) NULL, -- Исполненный объём в валюте заявки для частично или полностью исполненных заявок + "Extref" varchar(32) NULL, -- Внешняя ссылка, используется для обратной связи с внешними системами + "OnBehalfOfUID" decimal(10, 0) NULL, -- UID пользователя, от имени которого была выставлена заявка + "ClientQualifier" int NULL, -- Квалификатор клиента, от имени которого была выставлена заявка + "ClientShortCode" decimal(19, 0) NULL, -- Краткий идентификатор клиента, от имени которого была выставлена заявка + "InvestmentDecisionMakerQualifier" int NULL, -- Квалификатор принявшего решение о выставлении заявки + "InvestmentDecisionMakerShortCode" decimal(19, 0) NULL, -- Краткий идентификатор принявшего решение о выставлении заявки + "ExecutingTraderQualifier" int NULL, -- Квалификатор исполнившего заявку + "ExecutingTraderShortCode" decimal (19, 0) NULL, -- Краткий идентификатор исполнившего заявку + "SettleDate" timestamp without time zone NULL, -- Дата расчетов первой (ближней) ноги свопа + "SettleDate2" timestamp without time zone NULL, -- Дата расчетов второй (дальней) ноги свопа + "StartDate" timestamp without time zone NULL, -- Дата, начиная с которой допускается реализация права на совершение валютирования. Заполняется только для FLEX FORWARD + "OperationType" int NULL, -- Тип контракта + "VisibilityFactor" decimal(19, 2) NULL, -- Видимая часть в общей сумме заявки в % (точность 2 знака) + "VisibilityFactorScale" int NULL, -- Точность видимой части + "VisibleRepoValue" decimal(19, 8) NULL, -- Сумма РЕПО видимой части (точность валюты расчетов заявки/инструмента) + "TradingSession" int NULL, -- Идентификатор торговой сессии + "PriceEntryType" int NULL, -- Индикатор типа ввода значения цены заявки - по цене/по доходности/по средневзвешенной цене + "Qty2" decimal(28, 8) NULL, -- Количество второй части свопа + "Value2" decimal(19, 8) NULL, -- Объем второй части свопа + "LSecCode" varchar(12) NULL, -- Конкретный код инструмента при торговле корзинами КСУ + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + "FirstCurrCode" varchar(12) NULL, -- Базовая валюта + "FirstCurrQty" decimal(28, 8) NULL, -- Количество базовой валюты + "SecondCurrCode" varchar(12) NULL, -- Котируемая валюта + "SecondCurrQty" decimal(28, 8) NULL, -- Количество котируемой валюты + "ExternalQty" decimal(28, 8) NULL, -- Внешнее количество + "UpdUserId" varchar(32) NULL, -- Код трейдера, изменившего заявку + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + "ZSpread" decimal(19, 2) NULL, -- Z-спред по цене заявки / сделки / котировки + + PRIMARY KEY ("OrderNum", "ClassCode", "TradeDate") +); + + +-- Позиции по клиентским счетам +CREATE TABLE "FuturesHolding" +( + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "TrdAccId" varchar(12) NOT NULL, -- Код торгового счета + "Type" varchar(40) NOT NULL, -- Тип группировки торговых счетов + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "AvrPosnPrice" decimal(19, 8) NULL, -- Эффективная цена позиций + "CbplPlanned" decimal(19, 4) NULL, -- Стоимостная оценка планируемых чистых позиций + "CbplUsed" decimal(19, 4) NULL, -- Стоимостная оценка текущих чистых позиций + "Varmargin" decimal(19, 4) NULL, -- Оценка размера вариационной маржи по позициям + "PositionValue" decimal(19, 4) NULL, -- Стоимость позиций срочного рынка + "OpenBuys" decimal(10, 0) NULL, -- Объем (в контрактах) активных заявок на покупку + "OpenSells" decimal(10, 0) NULL, -- Объем (в контрактах) активных заявок на продажу + "StartBuy" decimal(10, 0) NULL, -- Количество длинных позиций на начало торгов, контрактов + "StartNet" decimal(10, 0) NULL, -- Чистые позиции по инструменту на начало торгов + "StartSell" decimal(10, 0) NULL, -- Количество коротких позиций на начало торгов + "TodayBuy" decimal(10, 0) NULL, -- Количество открытых длинных позиций в ходе текущей торговой сессии + "TodaySell" decimal(10, 0) NULL, -- Количество открытых коротких позиций в ходе текущей торговой сессии + "TotalNet" decimal(10, 0) NULL, -- Текущие чистые позиции + "TotalVarMargin" decimal(19, 6) NULL, -- Суммарная вариационная маржа по итогам основного клиринга, начисленная по всем позициям + "RealVarMargin" decimal(19, 6) NULL, -- Начисленная в ходе клиринга вариационная маржа + "SessionStatus" smallint NULL, -- Актуальный статус торговой сессии + "Premium" decimal(28, 8) NULL, -- Накопленная по сделкам премия по премиальным опционам + + PRIMARY KEY ("FirmId", "SecCode", "TrdAccId", "Type", "TradeDate") +); + + +-- Ограничения по клиентским счетам +CREATE TABLE "FuturesLimits" +( + "FirmId" varchar(12) NOT NULL, -- Идентификатор участника торгов + "TrdAccId" varchar(12) NOT NULL, -- Код торгового счета + "Type" varchar(64) NOT NULL, -- Тип лимита + "LoadDate" timestamp without time zone NOT NULL, -- Дата торгов + "CurrCode" varchar(12) NOT NULL, -- Валюта, в которой транслируется ограничение + + "Accruedint" decimal(19, 8) NULL, -- Накопленный доход + "CbpLimit" decimal(19, 4) NULL, -- Текущий лимит открытых позиций + "CbplPlanned" decimal(19, 4) NULL, -- Планируемые чистые позиции + "CbplUsed" decimal(19, 4) NULL, -- Текущие чистые позиции + "CbpPrevLimit" decimal(19, 4) NULL, -- Предыдущий лимит открытых позиций + "LiquidityCoeff" decimal(19, 4) NULL, -- Коэффициент ликвидности + "Varmargin" decimal(19, 4) NULL, -- Вариационная маржа + "CbplusedForOrders" decimal(19, 4) NULL, -- Величина ГО, зарезервированного под клиентские заявки + "CbplusedForPositions" decimal(19, 4) NULL, -- Величина ГО, зарезервированного под открытые клиентские позиции + "OptionsPremium" decimal(19, 4) NULL, -- Премии по опционам + "TsCommission" decimal(19, 4) NULL, -- Биржевые сборы + "Kgo" decimal(19, 6) NULL, -- Коэффициент клиентского гарантийного обеспечения + "LimitType" int NULL, -- Тип лимита + "RealVarMargin" decimal(19, 6) NULL, -- Начисленная в ходе клиринга вариационная маржа + "SessionStatus" smallint NULL, -- Актуальный статус торговой сессии + "RiskLevel" int NULL, -- Уровень риска клиента + "GoPlanned" decimal(28, 8) NULL, -- Какое будет ГО, если клиринг прямо сейчас + "GoWithoutOrders" decimal(28, 8) NULL, -- ГО без заявок с учетом текущего реализованного риска + "IndicativeVarMargin" decimal(28, 8) NULL, -- Индикативная вариационная маржа + "NetOptionsValue" decimal(28, 8) NULL, -- Нетто стоимости опционов + + PRIMARY KEY ("FirmId", "TrdAccId", "Type", "LoadDate", "CurrCode") +); + + +-- Клиентский портфель +CREATE TABLE "Portfolio" +( + "FirmId" varchar(12) NOT NULL, -- Код фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "LimitKind" varchar(5) NOT NULL, -- Срок расчетов + "CurrCode" varchar(12) NOT NULL, -- Валюта + "Tag" varchar(4) NOT NULL, -- Группа + + "PortfolioValue" decimal(28, 8) NULL, -- Стоимость клиентского портфеля + "PortfolioValueLongMainCurrency" decimal(28, 8) NULL, -- Стоимость длинных позиций по главной валюте + "PortfolioValueLongForeignCurrency" decimal(28, 8) NULL, -- Стоимость длинных позиций по иностранной валюте + "PortfolioValueLongSecurities" decimal(28, 8) NULL, -- Стоимость длинных позиций по ценным бумагам + "PortfolioValueLongFutures" decimal(28, 8) NULL, -- Стоимость длинных позиций по фьючерсам + "PortfolioValueLongOptions" decimal(28, 8) NULL, -- Стоимость длинных позиций по опционам + "PortfolioValueLongOther" decimal(28, 8) NULL, -- Стоимость длинных позиций по иным инструментам + "PortfolioValueShortMainCurrency" decimal(28, 8) NULL, -- Стоимость коротких позиций по главной валюте + "PortfolioValueShortForeignCurrency" decimal(28, 8) NULL, -- Стоимость коротких позиций по иностранной валюте + "PortfolioValueShortSecurities" decimal(28, 8) NULL, -- Стоимость коротких позиций по ценным бумагам + "PortfolioValueShortFutures" decimal(28, 8) NULL, -- Стоимость коротких позиций по фьючерсам + "PortfolioValueShortOptions" decimal(28, 8) NULL, -- Стоимость коротких позиций по опционам + "PortfolioValueShortOther" decimal(28, 8) NULL, -- Стоимость коротких позиций по иным инструментам + "InitMargin" float NULL, -- Начальная маржа + "InitMarginNetto" float NULL, -- Начальная маржа (рассчитанный с применением полного неттирования) + "InitMarginNettoSecurities" float NULL, -- Начальная маржа по ценным бумагам + "InitMarginNettoCurrency" float NULL, -- Начальная маржа по иностранной валюте + "InitMarginNettoFutures" float NULL, -- Начальная маржа по фьючерсам + "InitMarginNettoOptions" float NULL, -- Начальная маржа по опционам + "InitMarginNettoOther" float NULL, -- Начальная маржа по иным инструментам + "GroupAssetsUsed" smallint NULL, -- Признак использования множеств при расчете портфеля + "MinMargin" float NULL, -- Минимальная маржа + "MinMarginNetto" float NULL, -- Минимальная маржа (рассчитанный с применением полного неттирования) + "CorrectedMargin" float NULL, -- Скорректированная маржа + "Margin" decimal(19, 8) NULL, -- Уровень маржи + "Leverage" decimal(19, 2) NULL, -- Плечо + "OpenLimit" decimal(19, 8) NULL, -- Входящий лимит + "ValShort" decimal(28, 8) NULL, -- Шорты + "ValLong" decimal(28, 8) NULL, -- Лонги + "ValLongMargin" decimal(28, 8) NULL, -- Лонги МО + "ValLongAsset" decimal(19, 8) NULL, -- Лонги О + "LimAll" decimal(19, 8) NULL, -- Текущий лимит + "AvLimAll" decimal(19, 8) NULL, -- ДостТекЛимит + "LockedBuy" decimal(19, 8) NULL, -- БлокПокупка + "LockedBuyMargin" decimal(19, 8) NULL, -- БлокПок МО + "LockedBuyAsset" decimal(19, 8) NULL, -- БлокПок О + "LockedSell" decimal(19, 8) NULL, -- БлокПродажа + "LockedValueCoef" decimal(19, 8) NULL, -- БлокПокНеМарж + "LimBuy" decimal(19, 8) NULL, -- На покупку + "LimSell" decimal(19, 8) NULL, -- На продажу + "LimNonMargin" decimal(28, 8) NULL, -- НаПокупНеМаржин + "LimBuyAsset" decimal(19, 8) NULL, -- НаПокупОбесп + "OpenPos" decimal(19, 8) NULL, -- Оценка открытых позиций вместе с активными заявками + "InAssets" decimal(28, 8) NULL, -- Вход. активы + "Assets" decimal(28, 8) NULL, -- Тек. активы + "InAllAssets" decimal(28, 8) NULL, -- ВходСредства + "AllAssets" decimal(28, 8) NULL, -- ТекСредства + "ProfitLoss" decimal(19, 8) NULL, -- Прибыль/убытки + "RateChange" decimal(20, 8) NULL, -- ПроцИзмен + "CurrentBal" decimal(28, 8) NULL, -- Остаток + "MoneyLocked" decimal(19, 8) NULL, -- Заблокировано + "CurLeverage" decimal(19, 2) NULL, -- Текущее плечо + "MarginType" varchar(4) NULL, -- Тип клиента + "IsFutures" varchar(25) NULL, -- Сроч. cчет + "GoForPositions" decimal(19, 8) NULL, -- ГО поз. + "GoForOrders" decimal(19, 8) NULL, -- ГО заяв. + "VarMargin" decimal(19, 8) NULL, -- Вариац. маржа + "IsQualClient" varchar(16) NULL, -- ПовышУрРиска + "DeltaAssets" decimal(19, 8) NULL, -- Разница между текущими активами и активами для расчета лимитов + "Haircuts" decimal(19, 4) NULL, -- Сумма дисконтов + "RCV1" float NULL, -- НПР1 Норматив покрытия риска 1 + "RCV1Netto" float NULL, -- НПР1 Норматив покрытия риска 1 (рассчитанный с применением полного неттирования) + "RCV2" float NULL, -- НПР2 Норматив покрытия риска 2 + "RCV2Netto" float NULL, -- НПР2 Норматив покрытия риска 2 (рассчитанный с применением полного неттирования) + "Status" int NULL, -- Состояние стоимости портфеля относительно уровня маржи + "RiskLevel" int NULL, -- Уровень риска клиента + "FundsLevel" decimal(19,2) NULL, -- УДС (уровень достаточности средств) + "TOTAL_CASH_OPEN_BALANCE" decimal(28, 8) NULL, -- Полная сумма денежных остатков на начало дня + "TOTAL_CASH_CURR_BALANCE" decimal(28, 8) NULL, -- Полная сумма денежных остатков + "LIMIT_OPEN_POS" decimal(28, 8) NULL, -- Планируемые чистые позиции + "USED_LIM_OPEN_POS" decimal(28, 8) NULL, -- Текущие чистые позиции + "ACC_VAR_MARGIN" decimal(28, 8) NULL, -- Накопленная по сделкам вариационная маржа + "CL_VAR_MARGIN" decimal(28, 8) NULL, -- Вариационная маржа по итогам промклиринга + "FUT_ACCURED_INT" decimal(28, 8) NULL, -- Накопленный доход с учетом премии по опционам и биржевым сборам + "OPT_LIQUID_COST" decimal(28, 8) NULL, -- Ликвидационная стоимость опционов + "FUT_ASSET" decimal(28, 8) NULL, -- Сумма активов на срочном рынке + "FUT_TOTAL_ASSET" decimal(28, 8) NULL, -- Стоимость портфеля с учетом средств на срочном рынке + "FUT_DEBT" decimal(28, 8) NULL, -- Текущая задолженность на срочном рынке + "FUT_RATE_ASSET" decimal(28, 8) NULL, -- Достаточность средств + "FUT_RATE_ASSET_OPEN" decimal(28, 8) NULL, -- Достаточность средств (под открытые позиции) + "FUT_RATE_GO" decimal(19, 6) NULL, -- Коэффициент ликвидности гарантийного обеспечения + "PLANED_RATE_GO" decimal(19, 6) NULL, -- Ожидаемый коэффициент ликвидности гарантийного обеспечения + "CASH_LEVERAGE" decimal(19, 2) NULL, -- Кредитное плечо денежных средств + "FUT_POSITION_TYPE" varchar(4) NULL, -- Тип позиции на срочном рынке + + PRIMARY KEY ( "FirmId", "ClientCode", "LimitKind", "CurrCode", "Tag") +); + + +-- Текущие торги +CREATE TABLE "Params" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "accruedint" decimal(19, 6) NULL, -- Накопленный купонный доход + "bid" decimal(28, 8) NULL, -- Лучшая цена спроса + "biddepth" decimal(28, 8) NULL, -- Спрос по лучшей цене + "biddeptht" decimal(28, 8) NULL, -- Суммарный спрос + "buydepo" decimal(19, 4) NULL, -- Гарантийное обеспечение покупателя + "buydepo_hrc" decimal(19,4) NULL, -- ГО покупат. КПУР + "buydepo_mrc" decimal(19,4) NULL, -- ГО покупат. КСУР + "buydepo_lrc" decimal(19,4) NULL, -- ГО покупат. КНУР + "buydepo_erc" decimal(19,4) NULL, -- ГО покупат. КОУР + "change" decimal(19, 8) NULL, -- Разница цены последней к предыдущей сессии + "changetime" timestamp without time zone NULL, -- Время последнего изменения + "chngclose" decimal(19, 6) NULL, -- Изменение текущего индекса по сравнению со значением закрытия + "chngopen" decimal(19, 6) NULL, -- Изменение текущего индекса по сравнению со значением открытия + "closeprice" decimal(19, 8) NULL, -- Цена периода закрытия + "closetime" timestamp without time zone NULL, -- Время закрытия предыдущих торгов + "closeval" decimal(19, 4) NULL, -- Значение индекса на момент закрытия торгов + "closeyield" decimal(19, 4) NULL, -- Доходность закрытия + "couponvalue" decimal(19, 6) NULL, -- Размер купона + "currentvalue" decimal(19, 4) NULL, -- Текущее значение + "high" decimal(19, 8) NULL, -- Максимальная цена сделки + "highbid" decimal(19, 8) NULL, -- Лучшая цена спроса сегодня + "last" decimal(19, 8) NULL, -- Цена последней сделки + "lastchange" decimal(19, 4) NULL, -- % изменения от закрытия + "lasttoprevstlprc" decimal(19, 4) NULL, -- Разница цены последней к предыдущей сессии + "lastvalue" decimal(19, 4) NULL, -- Значение закрытия + "lotsize" decimal(19, 0) NULL, -- Размер лота + "low" decimal(19, 8) NULL, -- Минимальная цена сделки + "lowoffer" decimal(19, 8) NULL, -- Лучшая цена предложения сегодня + "marketprice" decimal(19, 8) NULL, -- Рыночная цена вчера + "marketpricetoday" decimal(19, 8) NULL, -- Рыночная цена + "maxoutvolume" decimal(19, 4) NULL, -- Лимит объема активных заявок (в контрактах) + "negnumtrades" decimal(19, 4) NULL, -- Количество внесистемных сделок за сегодня + "negvaltoday" decimal(19, 4) NULL, -- Оборот внесистемных в деньгах + "numbids" decimal(19, 4) NULL, -- Количество заявок на покупку + "numcontracts" decimal(19, 4) NULL, -- Количество открытых позиций + "numoffers" decimal(19, 0) NULL, -- Количество заявок на продажу + "numtrades" decimal(19, 0) NULL, -- Количество сделок за сегодня + "offer" decimal(28, 8) NULL, -- Лучшая цена предложения + "offerdepth" decimal(28, 8) NULL, -- Предложение по лучшей цене + "offerdeptht" decimal(28, 8) NULL, -- Суммарное предложение + "open" decimal(19, 8) NULL, -- Цена открытия + "openval" decimal(19, 4) NULL, -- Значение индекса на момент открытия торгов + "prevprice" decimal(19, 8) NULL, -- Цена закрытия + "prevsettleprice" decimal(19, 8) NULL, -- Предыдущая расчетная цена + "prevwaprice" decimal(19, 8) NULL, -- Предыдущая оценка + "pricemax" decimal(19, 8) NULL, -- Максимально возможная цена + "pricemin" decimal(19, 8) NULL, -- Минимально возможная цена + "priceminusprevwa" decimal(19, 8) NULL, -- Разница цены последней к предыдущей оценке + "pricemvtlimit" decimal(19, 8) NULL, -- Лимит изменения цены + "pricemvtlimitt1" decimal(19, 8) NULL, -- Лимит изменения цены + "primarydist" varchar(128) NULL, -- Размещение + "qty" decimal(28, 8) NULL, -- Количество инструментов в последней сделке + "repo2price" decimal(19, 8) NULL, -- Цена второй части РЕПО + "selldepo" decimal(19, 4) NULL, -- Гарантийное обеспечение продавца + "selldepo_hrc" decimal(19,4) NULL, -- ГО продавца КПУР + "selldepo_mrc" decimal(19,4) NULL, -- ГО продавца КСУР + "selldepo_lrc" decimal(19,4) NULL, -- ГО продавца КНУР + "selldepo_erc" decimal(19,4) NULL, -- ГО продавца КОУР + "status" varchar(128) NULL, -- Статус + "stepprice" decimal(19, 8) NULL, -- Стоимость шага цены + "realminstep" decimal(19,8) NULL, -- Минимальный шаг цены + "time" timestamp without time zone NULL, -- Время последней сделки + "theorprice" decimal(19, 8) NULL, -- Теоретическая цена + "tradechange" decimal(19, 8) NULL, -- Разница цены последней к предыдущей сделки + "tradingstatus" varchar (128) NULL, -- Состояние сессии + "valtoday" decimal(19, 4) NULL, -- Оборот в деньгах + "value" decimal(19, 4) NULL, -- Оборот в деньгах последней сделки + "volatility" decimal(19, 4) NULL, -- Волатильность опциона + "voltoday" decimal(19, 4) NULL, -- Количество во всех сделках + "waprice" decimal(19, 8) NULL, -- Средневзвешенная цена + "yield" decimal(19, 4) NULL, -- Доходность последней сделки + "yieldatprevwapri" decimal(19, 4) NULL, -- Доходность по предыдущей оценке + "yieldatwaprice" decimal(19, 8) NULL, -- Доходность по оценке + "bgonp" decimal(19, 4) NULL, -- Базовое гарантийное обеспечение по непокрытым позициям + "bgonp_hrc" decimal(19,4) NULL, -- БГО по непокрытым позициям КПУР + "bgonp_mrc" decimal(19,4) NULL, -- БГО по непокрытым позициям КСУР + "bgonp_lrc" decimal(19,4) NULL, -- БГО по непокрытым позициям КНУР + "bgonp_erc" decimal(19,4) NULL, -- БГО по непокрытым позициям КОУР + "bgop" decimal(19, 4) NULL, -- Базовое гарантийное обеспечение по покрытым позициям + "bgop_hrc" decimal(19,4) NULL, -- БГО по покрытым позициям КПУР + "bgop_mrc" decimal(19,4) NULL, -- БГО по покрытым позициям КСУР + "bgop_lrc" decimal(19,4) NULL, -- БГО по покрытым позициям КНУР + "bgop_erc" decimal(19,4) NULL, -- БГО по покрытым позициям КОУР + "highval" decimal(19, 4) NULL, -- Максимальное значение индекса + "ichange" decimal(19, 4) NULL, -- Изменение индекса + "iopen" decimal(19, 4) NULL, -- Открытие индекса + "lowval" decimal(19, 4) NULL, -- Минимальное значение индекса + "pchange" decimal(19, 4) NULL, -- Процент изменения + "quantity" decimal(19, 4) NULL, -- Количество контрактов в последней сделке + "settleprice" decimal(19, 8) NULL, -- Расчетная цена + "strike" decimal(19, 4) NULL, -- Цена страйк + "nextcoupon" timestamp without time zone NULL, -- Дата выплаты купона + "admittedquote" decimal(19, 6) NULL, -- Признаваемая котировка + "issuesize" decimal(28, 8) NULL, -- Объем обращения + "prevdate" timestamp without time zone NULL, -- Дата предыдущего торгового дня + "lcurrentprice" decimal(19, 8) NULL, -- Текущая цена + "quotebasis" varchar(255) NULL, -- Тип цены + "couponperiod" decimal(19, 0) NULL, -- Длительность купона + "buybackprice" decimal(19, 8) NULL, -- Базовая цена для расчета доходности + "buybackdate" timestamp without time zone NULL, -- Дата, к которой рассчитывается доходность + "duration" decimal(19, 4) NULL, -- Дюрация + "baseprice" decimal(19,8) NULL, -- Баз.курс + "clprice" decimal(19,8) NULL, -- Кот. клиринга + "clstate" varchar(32) NULL, -- Статус кл. + "counterprice" decimal(19,8) NULL, -- Цена контраг. + "crossrate" decimal(19,4) NULL, -- Курс + "curstepprice" varchar(32) NULL, -- Валюта шага цены + "endtime" timestamp without time zone NULL, -- Окончание + "evnendtime" timestamp without time zone NULL, -- Окончание веч. + "evnstarttime" timestamp without time zone NULL, -- Начало веч. + "expdate" timestamp without time zone NULL, -- Дата исп. + "ispercent" varchar(32) NULL, -- Тип цены + "lastbid" decimal(19,6) NULL, -- Спрос сессии + "lastoffer" decimal(19,6) NULL, -- Предл.сессии + "lcloseprice" decimal(19,8) NULL, -- Оф.цена закр. + "marg" varchar(32) NULL, -- Марж. + "marketprice2" decimal(19,8) NULL, -- Рын.цена2 + "min_curr_last" decimal(19,8) NULL, -- Мин.тек.цена + "monendtime" timestamp without time zone NULL, -- Окончание утр. + "monstarttime" timestamp without time zone NULL, -- Начало утр. + "openperiodprice" decimal(19,8) NULL, -- Цена предторг. + "percentrate" decimal(19,4) NULL, -- Ставка + "prevadmittedquot" decimal(19,6) NULL, -- Призн.кот.пред. + "prevlegalclosepr" decimal(19,8) NULL, -- Пред.цена закр. + "realvmprice" decimal(19,8) NULL, -- Рын. котир. + "sectype" varchar(32) NULL, -- Тип + "starttime" timestamp without time zone NULL, -- Начало + "settlecode" varchar(16) NULL, -- Код расчетов по умолчанию + "steppricecl" decimal(19,8) NULL, -- Стоимость шага цены для клиринга по инструментам срочного рынка МБ, номинированным в другой валюте + "steppriceprcl" decimal(19,8) NULL, -- Стоимость шага цены для промежуточного клиринга по инструментам срочного рынка МБ, номинированным в другой валюте + "min_curr_last_ti" timestamp without time zone NULL, -- Время изменения минимальной текущей цены + "prevlotsize" decimal(19,4) NULL, -- Предыдущее значение размера лота + "lotsizechangedat" timestamp without time zone NULL, -- Дата последнего изменения размера лота + "listlevel" int NULL, -- Уровень листинга + "Comments" varchar(128) NULL, -- Комментарии + "AgentID" varchar(12) NULL, -- Код фирмы-агента по размещению инструмента + "auction_id" varchar(19) NULL, -- Пользовательский идентификатор аукциона + "putoptiondate" timestamp without time zone NULL, -- Дата пут-опциона + "couptype" text NULL, -- Тип купона + "nomtype" text NULL, -- Тип номинала + "spread2benchmark" decimal(28,8) NULL, -- Значение спреда к индикатору + + PRIMARY KEY ( "ClassCode", "SecCode", "TradeDate") +); + + +-- Позиции по деньгам (используется при ведении позиций по сроку расчетов) +CREATE TABLE "MoneyLimits" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки лимита в таблицу + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "Tag" varchar(4) NOT NULL, -- Код позиции + "CurrCode" varchar(12) NOT NULL, -- Код валюты + "LimitKind" varchar(5) NOT NULL, -- Тип лимита + + "Leverage" decimal(19, 4) NULL, -- Плечо клиента + "LockedValueCoef" decimal(19, 4) NULL, -- Количество заблокированных денежных средств под покупку немаржинальных бумаг + "CurrentBal" decimal(28, 8) NULL, -- Текущий остаток денежных средств + "CurrentLimit" decimal(28, 8) NULL, -- Текущий лимит + "Locked" decimal(19, 8) NULL, -- Количество заблокированных средств + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "OpenLimit" decimal(28, 8) NULL, -- Входящий лимит + "LockedMarginValue" decimal(19, 6) NULL, -- Заблокировано на покупку маржинальных бумаг + "AwgPositionPrice" decimal(19, 8) NULL, -- Средняя цена приобретения позиции (для денежной схемы) + "OrdersCollateral" decimal(19, 8) NULL, -- ГО заявок + "PositionsCollateral" decimal(19, 8) NULL, -- ГО позиций + + PRIMARY KEY ("LoadDate", "FirmId", "ClientCode", "Tag", "CurrCode", "LimitKind") +); + + +-- Позиции по деньгам (используется при ведении позиций по календарным датам) +CREATE TABLE "MoneyLimitsByDates" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки лимита в таблицу + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "Tag" varchar(4) NOT NULL, -- Код позиции + "CurrCode" varchar(12) NOT NULL, -- Код валюты + "SettleDate" timestamp without time zone NOT NULL, -- Календарная дата + + "Leverage" decimal(19, 4) NULL, -- Плечо клиента + "LockedValueCoef" decimal(19, 4) NULL, -- Количество заблокированных денежных средств под покупку немаржинальных бумаг + "CurrentBal" decimal(28, 8) NULL, -- Текущий остаток денежных средств + "CurrentLimit" decimal(28, 8) NULL, -- Текущий лимит + "Locked" decimal(19, 8) NULL, -- Количество заблокированных средств + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "OpenLimit" decimal(28, 8) NULL, -- Входящий лимит + "LockedMarginValue" decimal(19, 6) NULL, -- Заблокировано на покупку маржинальных бумаг + "AwgPositionPrice" decimal(19, 8) NULL, -- Средняя цена приобретения позиции (для денежной схемы) + "OrdersCollateral" decimal(19, 8) NULL, -- ГО заявок + "PositionsCollateral" decimal(19, 8) NULL, -- ГО позиций + + PRIMARY KEY ("LoadDate", "FirmId", "ClientCode", "Tag", "CurrCode", "SettleDate") +); + + +-- Позиции по инструментам (используется при ведении позиций по сроку расчетов) +CREATE TABLE "DepoLimits" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки лимита в таблицу + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "Trdacc" varchar(12) NOT NULL, -- Торговый счет + "LimitKind" varchar(5) NOT NULL, -- Тип лимита + + "CurrentBal" decimal(28, 8) NULL, -- Текущий остаток ценных бумаг + "CurrentLimit" decimal(28, 8) NULL, -- Текущий лимит + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "OpenLimit" decimal(28, 8) NULL, -- Входящий лимит + "LockedBuy" decimal(28, 8) NULL, -- Количество ценных бумаг в активных заявках на покупку + "LockedBuyValue" decimal(28, 8) NULL, -- Количество денежных средств в активных заявках на покупку данной бумаги + "LockedSell" decimal(28, 8) NULL, -- Количество ценных бумаг, заблокированных в активных заявках на продажу + "LockedSellValue" decimal(28, 8) NULL, -- Количество денежных средств в активных заявках на продажу данной бумаги + "LockedOut" decimal(28, 8) NULL, -- Количество ценных бумаг, заблокированных в поручениях на вывод + "AwgPositionPrice" decimal(19, 8) NULL, -- Средневзвешенная цена приобретения позиции + "WAPositionPriceCurrency" varchar(12) NULL, -- Валюта цены приобретения (вычислимое) + "WAPositionPriceCurrencyAsIs" varchar(12) NULL, -- Валюта цены приобретения + + PRIMARY KEY ("LoadDate", "FirmId", "ClientCode", "SecCode", "Trdacc", "LimitKind") +); + + +-- Позиции по инструментам (используется при ведении позиций по календарным датам) +CREATE TABLE "DepoLimitsByDates" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки лимита в таблицу + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "Trdacc" varchar(12) NOT NULL, -- Торговый счет + "SettleDate" timestamp without time zone NOT NULL, -- Календарная дата + + "CurrentBal" decimal(28, 8) NULL, -- Текущий остаток ценных бумаг + "CurrentLimit" decimal(28, 8) NULL, -- Текущий лимит + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "OpenLimit" decimal(28, 8) NULL, -- Входящий лимит + "LockedBuy" decimal(28, 8) NULL, -- Количество ценных бумаг в активных заявках на покупку + "LockedBuyValue" decimal(28, 8) NULL, -- Количество денежных средств в активных заявках на покупку данной бумаги + "LockedSell" decimal(28, 8) NULL, -- Количество ценных бумаг, заблокированных в активных заявках на продажу + "LockedSellValue" decimal(28, 8) NULL, -- Количество денежных средств в активных заявках на продажу данной бумаги + "LockedOut" decimal(28, 8) NULL, -- Количество ценных бумаг, заблокированных в поручениях на вывод + "AwgPositionPrice" decimal(19, 8) NULL, -- Средневзвешенная цена приобретения позиции + "WAPositionPriceCurrency" varchar(12) NULL, -- Валюта цены приобретения (вычислимое) + "WAPositionPriceCurrencyAsIs" varchar(12) NULL, -- Валюта цены приобретения + + PRIMARY KEY ("LoadDate", "FirmId", "ClientCode", "SecCode", "Trdacc", "SettleDate") +); + + +-- Сделки для исполнения +CREATE TABLE "UsTrades" +( + "TradeNum" decimal(20, 0) NOT NULL, -- Номер сделки в торговой системе + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "Operation" varchar(40) NOT NULL, -- Тип операции (продажа или покупка) + + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "Accruedint" decimal(19, 8) NULL, -- Процентный доход, выраженный в рублях + "CPReportNum" decimal(20, 0) NULL, -- Номер отчета партнера + "Price" decimal(19, 8) NULL, -- Цена за одну ценную бумагу + "Price1" decimal(19, 8) NULL, -- Цена первой части РЕПО + "Price2" decimal(19, 8) NULL, -- Цена выкупа второй части РЕПО + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО, в процентах + "RepoTradeNum" decimal(20, 0) NULL, -- Номер сделки первой части РЕПО + "Value" decimal(19, 8) NULL, -- Объем сделки, выраженный в рублях + "Amount" decimal(19, 8) NULL, -- Сумма обязательства + "ExpectedRepo2Value" decimal(19, 8) NULL, -- Стоимость выкупа после взноса + "ExpectedRepoValue" decimal(19, 8) NULL, -- Сумма РЕПО после взноса + "ExpectedReturnValue" decimal(19, 8) NULL, -- Сумма возврата после взноса + "Repo2Value" decimal(19, 8) NULL, -- Объем выкупа РЕПО + "RepoValue" decimal(19, 8) NULL, -- Сумма РЕПО + "ReturnValue" decimal(19, 8) NULL, -- Сумма возврата РЕПО + "TSCommission" decimal(19, 8) NULL, -- Комиссия торговой системы, руб. + "Discount" decimal(19, 8) NULL, -- Дисконт % + "ExpectedDiscount" decimal(19, 8) NULL, -- Дисконт после взноса % + "LowerDiscount" decimal(19, 8) NULL, -- Нижний предел дисконта % + "UpperDiscount" decimal(19, 8) NULL, -- Верхний предел дисконта % + "Qty" decimal(28, 8) NULL, -- Количество бумаг, в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг, в штуках + "UsTradeDate" timestamp without time zone NULL, -- Дата сделки + "Balance" decimal(28, 8) NULL, -- Неисполненный остаток, в лотах + "Balance_pcs" decimal(28, 8) NULL, -- Неисполненный остаток, в штуках + "ExpectedQuantity" decimal(28, 8) NULL, -- Количество после взноса + "ReportTradeDate" timestamp without time zone NULL, -- Дата заключения исходной сделки РЕПО + "BlockSecurities" varchar(3) NULL, -- Блокировка обеспечения + "OperationType" varchar(13) NULL, -- Операция + "RepoTerm" int NULL, -- Срок РЕПО + "Type" varchar(64) NULL, -- Тип внебиржевой сделки + "UrgencyFlags" varchar(64) NULL, -- Исполнить + "NextDaySettle" smallint NULL, -- Исполнить завтра + "Account" varchar(12) NULL, -- Код торгового счета + "BrokerRef" varchar(34) NULL, -- Комментарий + "CPAccount" varchar(12) NULL, -- Код торгового счета партнера + "CPFirmId" varchar(12) NULL, -- Код фирмы партнера + "FirmId" varchar(12) NULL, -- Код фирмы, от имени которой была совершена сделка + "FirmName" varchar(128) NULL, -- Фирма, от имени которой была совершена сделка + "CPFirmName" varchar(128) NULL, -- Фирма партнера + "SettleCode" varchar(12) NULL, -- Код расчетов по сделке + "ClientCode" varchar(12) NULL, -- Код клиента + "DealState" varchar(32) NULL, -- Состояние сделки + "ReportNum" decimal(20, 0) NULL, -- Номер отчета + "Settled" varchar(32) NULL, -- Состояние расчетов по сделке + "ClearingType" varchar(32) NULL, -- Тип процедуры расчетов по сделке + "ReportCommission" decimal(19, 8) NULL, -- Размер комиссионного вознаграждения за исполнение сделки срочным отчетом + "CouponPayment" decimal(19, 8) NULL, -- Объем купонной выплаты, прошедшей со дня последних торгов по бумагам, входящим в обеспечение сделки РЕПО + "CouponPaymentDate" timestamp without time zone NULL, -- Дата прошедшей купонной выплаты + "PrincipalPayment" decimal(19, 8) NULL, -- Объем выплаты по основному долгу, прошедшей со дня последних торгов по бумагам, входящим в обеспечение сделки РЕПО + "PrincipalPaymentDate" timestamp without time zone NULL, -- Дата прошедшей выплаты по основному долгу (амортизации) + "OrderNum" decimal(20, 0) NULL, -- Номер заявки + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов + "Flags" int NULL, -- Набор битовых флагов + "UsTradeSettleDateTime" timestamp without time zone NULL, -- Дата и время исполнения сделки + "CompVal" decimal(19, 8) NULL, -- Сумма отступного в валюте сделки + "ConfirmReport" decimal(19, 0) NULL, -- Номер поручения + "ParentTradeNo" decimal(20, 0) NULL, -- Номер витринной сделки + "PreciseBalance" decimal(28, 8) NULL, -- Количество бумаг к исполнению по сделке + "ConfirmTime" timestamp without time zone NULL, -- Время подтверждения + "BankAccId" varchar(12) NULL, -- Код позиции + "BankId" varchar(12) NULL, -- Идентификатор расчетной организации + "ExFlags" int NULL, -- Признак сделки для исполнения + "ExtRef" varchar(32) NULL, -- Внешняя ссылка + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + "BenchmarkChangeDate" timestamp without time zone NULL, -- Ближайшая дата определения нового значения индикатора + "BenchmarkValue" decimal(19, 8) NULL, -- Значение индикатора + "CancelReason" varchar(128) NULL, -- Причина отмены + "DepositIntent" int NULL, -- тип сделки депозита + "OpenRepo2Value" decimal(28, 8) NULL, -- Стоимость выкупа РЕПО с открытой датой + "OpenRepo2Date" timestamp without time zone NULL, -- День Т+1 для сделок РЕПО с открытой датой + "OpenRepoStatus" int NULL, -- Статус РЕПО с открытой датой + "OpenRepoReportNo" decimal(20, 0) NULL, -- Номер отчёта на закрытие сделки РЕПО с открытой датой + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + "OpenRepoEverGreenTerm" int NULL, -- Период до исполнения второй части сделки РЕПО с открытой датой + "DueDate" timestamp without time zone NULL, -- Надлежащая дата расчетов сделки + "EarlySettleStatus" smallint NULL, -- Досрочное исполнение + "EarlySettleReportNum" decimal(20, 0) NULL, -- Номер отчета на досрочное исполнение + "EarlySettleCPReportNum" decimal(20, 0) NULL, -- Номер отчета на досрочное исполнение от партнера + "PercentPeriod" smallint NULL, -- Период процентных выплат + "PlannedInterest" decimal(28, 8) NULL, -- Сумма будущих процентных выплат + + PRIMARY KEY ("TradeNum", "TradeDate", "ClassCode", "Operation") +); + + +-- Таблица заявок на внебиржевые сделки +CREATE TABLE "NegDeals" +( + "NegDealNum" decimal(20, 0) NOT NULL, -- Номер заявки на внебиржевые сделки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "Operation" varchar(40) NOT NULL, -- Тип операции (продажа или покупка) + + "SecCode" varchar(12) NULL, -- Код инструмента + "Accruedint" decimal(19, 8) NULL, -- Процентный доход, выраженный в рублях + "Price" decimal(19, 8) NULL, -- Цена за одну ценную бумагу + "Price2" decimal(19, 8) NULL, -- Цена выкупа второй части РЕПО + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО, в процентах + "RefundRate" decimal(19, 4) NULL, -- Ставка фиксированного возмещения + "Value" decimal(19, 8) NULL, -- Объем сделки, выраженный в рублях + "Yield" decimal(19, 4) NULL, -- Доходность по цене заявки + "LowerDiscount" decimal(19, 8) NULL, -- Нижний предел дисконта, % + "StartDiscount" decimal(19, 8) NULL, -- Начальный дисконт, % + "UpperDiscount" decimal(19, 8) NULL, -- Верхний предел дисконта, % + "Repo2Value" decimal(19, 8) NULL, -- Объем выкупа РЕПО + "RepoValue" decimal(19, 8) NULL, -- Сумма РЕПО + "OriginRepoValue" decimal(19, 8) NULL, -- Сумма РЕПО первоначальная + "OriginDiscount" decimal(19, 8) NULL, -- Дисконт (%) первоначальный + "Qty" decimal(28, 8) NULL, -- Количество бумаг, в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг, в штуках + "UID" int NULL, -- UID пользователя QUIK + "Balance" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в лотах + "Balance_pcs" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в штуках + "OriginQty" decimal(28, 8) NULL, -- Количество первоначальное + "BlockSecurities" varchar (12) NULL, -- Блокировка обеспечения + "RepoEntry" varchar (32) NULL, -- Тип ввода заявки РЕПО + "RepoTerm" int NULL, -- Срок РЕПО + "Account" varchar(12) NULL, -- Код торгового счета + "BrokerRef" varchar(34) NULL, -- Комментарий, обычно: <кодклиента>/<номер поручения> + "CPFirmId" varchar(12) NULL, -- Код фирмы партнера + "CPFirmName" varchar(128) NULL, -- Фирма партнера + "CPUserId" varchar(32) NULL, -- Код трейдера, которому адресована заявка + "FirmId" varchar(12) NULL, -- Код фирмы, от имени которой была совершена сделка + "FirmName" varchar(128) NULL, -- Фирма, от имени которой была совершена сделка + "MatchRef" varchar(10) NULL, -- Ссылка + "SettleDate" timestamp without time zone NULL, -- Дата расчетов + "SettleCode" varchar(12) NULL, -- Код расчетов по сделке + "UserId" varchar(32) NULL, -- Код трейдера, который ввел заявку + "ClientCode" varchar(12) NULL, -- Код клиента (содержится также в виде подстроки в параметре BrokerRef) + "Direction" varchar(30) NULL, -- Направленность заявки (отправлена/получена) + "DealState" varchar(20) NULL, -- Состояние сделки (активна, исполнена или снята) + "ActivationDate" timestamp without time zone NULL, -- Дата активации адресной заявки + "ActivationTime" timestamp without time zone NULL, -- Время активации адресной заявки + "QuoteNo" decimal(20, 0) NULL, -- Номер встречной безадресной заявки + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов + "Flags" int NULL, -- Набор битовых флагов + "BankAccId" varchar(12) NULL, -- Идентификатор расчетного счета/кода в клиринговой организации + "LinkedOrder" decimal(20, 0) NULL, -- Исходный номер заявки + "NegDealDateTime" timestamp without time zone NULL, -- Дата и время регистрации внебиржевой завки в торговой системе + "WithdrawDateTime" timestamp without time zone NULL, -- Дата и время снятия внебиржевой заявки + "CanceledUserId" decimal(10, 0) NULL, -- Идентификатор пользователя, отказавшегося от сделки + "LSecCode" varchar(12) NULL, -- Приоритетное обеспечение + "SystemRef" varchar(12) NULL, -- Системная ссылка. Дополнительная информация, передаваемая торговой системой + "PriceCurrency" varchar(12) NULL, -- Валюта, в которой указана цена адресной заявки + "OrderExchangeCode" varchar(64) NULL, -- Биржевой номер заявки + "Extref" varchar(32) NULL, -- Внешняя ссылка, используется для обратной связи с внешними системами + "Period" int NULL, -- Период торговой сессии, когда была подана заявка + "ClientQualifier" int NULL, -- Квалификатор клиента, от имени которого была выставлена заявка. Описание такое же, как в аналогичном поле в заявках + "ClientShortCode" decimal(19, 0) NULL, -- Краткий идентификатор клиента, от имени которого была выставлена заявка + "InvestmentDecisionMakerQualifier" int NULL, -- Квалификатор принявшего решение о выставлении заявки + "InvestmentDecisionMakerShortCode" decimal(19, 0) NULL, -- Краткий идентификатор принявшего решение о совершении сделки + "ExecutingTraderQualifier" int NULL, -- Квалификатор исполнившего заявку + "ExecutingTraderShortCode" decimal(19, 0) NULL, -- Краткий идентификатор исполнившего заявку + "Benchmark" varchar(12) NULL, -- Индикатор, относительно которого задана плавающая ставка РЕПО + "RejectReason" varchar(128) NULL, -- Причина отмены + "OpenRepo2Value" decimal(28, 8) NULL, -- Стоимость выкупа РЕПО с открытой датой + "OpenRepo2Date" timestamp without time zone NULL, -- День Т+1 для сделок РЕПО с открытой датой + "ExtNegDealFlags" decimal(19, 0) NULL, -- Расширенные флаги (из торговой системы) + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + "FilledValue" decimal(28, 8) NULL, -- Исполненный объём в валюте заявки для частично или полностью исполненных заявок + "ValueEntryType" varchar(16) NULL, -- Способ указания размера заявки + "RepoValueBalance" decimal(28, 8) NULL, -- Остаток суммы РЕПО + "Repo2ValueBalance" decimal(28, 8) NULL, -- Остаток суммы выкупа РЕПО + "OpenRepoEverGreenTerm" int NULL, -- Период до исполнения второй части сделки РЕПО с открытой датой + "ZSpread" decimal(19, 2) NULL, -- Z-спред по цене заявки / сделки / котировки + "PercentPeriod" smallint NULL, -- Период процентных выплат + "OnBehalfOfUID" int NULL, -- UID пользователя, от имени которого выставлена заявка + + PRIMARY KEY ("NegDealNum", "TradeDate", "ClassCode", "Operation") +); + + +-- Таблица заявок-отчетов на сделки РПС +CREATE TABLE "NegReports" +( + "ReportNum" decimal(20, 0) NOT NULL, -- Идентификационный номер заявки-отчета + "ClassCode" varchar(12) NOT NULL, -- Код класса + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "Value" decimal(19, 8) NULL, -- Объем сделки, выраженный в рублях + "Qty" decimal(28, 8) NULL, -- Количество бумаг, в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг, в штуках + "WithdrawTime" timestamp without time zone NULL, -- Время снятия отчета + "Account" varchar(12) NULL, -- Код торгового счета + "CPAccount" varchar(12) NULL, -- Код торгового счета партнера + "CPFirmId" varchar(12) NULL, -- Код фирмы партнера + "CPFirmName" varchar(128) NULL, -- Фирма партнера + "FirmId" varchar(12) NULL, -- Код фирмы + "FirmName" varchar(128) NULL, -- Фирма + "UserId" varchar(32) NULL, -- Код трейдера, который ввел отчет + "Direction" varchar(30) NULL, -- Направленность заявки (отправлена/получена) + "ReportState" varchar(30) NULL, -- Состояние подтверждения отчета (исполнен, снят или ждет исполнения) + "Commission" decimal(19, 8) NULL, -- Объем комиссии по сделке, выраженный в руб + "ReportKind" varchar(32) NULL, -- Вид отчета + "ReportType" varchar(32) NULL, -- Тип отчёта + "Flags" int NULL, -- Набор битовых флагов + "ReportDateTime" timestamp without time zone NULL, -- Дата и время регистрации отчета + "Compval" decimal(19, 8) NULL, -- Сумма отступного в валюте сделки + "CompBS" int NULL, -- Направленность отступного + + PRIMARY KEY ("ReportNum", "ClassCode", "TradeDate") +); + + +-- Позиции участника по деньгам +CREATE TABLE "AccountPositions" +( + "CurrCode" varchar(12) NOT NULL, -- Код валюты + "Description" varchar(128) NOT NULL, -- Описание группы + "FirmId" varchar(12) NOT NULL, -- Код фирмы + "Tag" varchar(4) NOT NULL, -- Код позиции + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки позиции в таблицу + "BankAccId" varchar(12) NOT NULL, -- Идентификатор расчетного счета/кода в клиринговой организации + + "CurrentPos" decimal(19, 4) NULL, -- Текущий остаток + "OpenBal" decimal(19, 4) NULL, -- Входящий остаток + "PlannedPos" decimal(19, 4) NULL, -- Плановая позиция + "OrderBuy" decimal(19, 4) NULL, -- В заявках на продажу + "OrderSell" decimal(19, 4) NULL, -- В заявках на покупку + "Netto" decimal(19, 4) NULL, -- Нетто-позиция + "PlannedBal" decimal(19, 4) NULL, -- Плановый остаток + "MarginCall" decimal(19, 4) NULL, -- Маржинальное требование на начало торгов + "SettleBal" decimal(19, 4) NULL, -- Плановая позиция после проведения расчетов + "Flags" int NULL, -- Битовое поле: Бит0 - Флаг включенного внешнего ограничения Бит1 - Флаг включенного внутреннего ограничения*/ + "Debit" decimal(19,2) NULL, -- Суммарный объем зачисленных на счет денежных средств decimal(19,2) AddDouble + "Credit" decimal(19,2) NULL, -- Суммарный объем списанных со счета денежных средств + "PosnGroup" int NULL, -- Группа позиций + + PRIMARY KEY ("CurrCode", "Description", "FirmId", "Tag", "LoadDate", "BankAccId") +); + + +-- Позиции участника по инструментам +CREATE TABLE "FirmDepoAccount" +( + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "FirmId" varchar(12) NOT NULL, -- Код фирмы + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки позиции в таблицу + + "CurrentPos" decimal(28, 8) NULL, -- Текущий остаток + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "PlannedPosBuy" decimal(28, 8) NULL, -- Покупка план. + "PlannedPosSell" decimal(28, 8) NULL, -- Продажа план. + "UsQtyB" decimal(28, 8) NULL, -- Куплено + "UsQtyS" decimal(28, 8) NULL, -- Продано + "SecName" varchar(128) NULL, -- Полное наименование инструмента + + PRIMARY KEY ("SecCode", "FirmId", "LoadDate") +); + + +-- Позиции участника по торговым счетам +CREATE TABLE "SecDepoAccount" +( + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "FirmId" varchar(12) NOT NULL, -- Код фирмы + "DepAccId" varchar(12) NOT NULL, -- Код депо + "TrdAccId" varchar(12) NOT NULL, -- Код торгового счета + "LoadDate" timestamp without time zone NOT NULL, -- Дата загрузки позиции в таблицу + + "CurrentPos" decimal(28, 8) NULL, -- Текущий остаток + "OpenBal" decimal(28, 8) NULL, -- Входящий остаток + "PlannedPosBuy" decimal(28, 8) NULL, -- Покупка план. + "PlannedPosSell" decimal(28, 8) NULL, -- Продажа план. + "SecName" varchar(128) NULL, -- Полное наименование инструмента + "PlanBal" decimal(28, 8) NULL, -- Контрольный остаток простого клиринга, равен входящему остатку минус плановая позиция на продажу, включенная в простой клиринг + "UsQtyB" decimal(28, 8) NULL, -- Куплено + "UsQtyS" decimal(28, 8) NULL, -- Продано + "Planned" decimal(28, 8) NULL, -- Плановый остаток, равен текущему остатку минус плановая позиция на продажу + "BankAccId" varchar(12) NULL, -- Идентификатор расчетного счета/кода в клиринговой организации + "FirmUse" varchar(16) NULL, -- Признак счета обеспечения + "SettleBal" decimal(28, 8) NULL, -- Плановая позиция после проведения расчетов + "PlannedCovered" decimal(28, 8) NULL, -- Плановая позиция Т+ + "LimitSell" decimal(28, 8) NULL, -- Внешнее ограничение плановой позиции + "CCUncoveredLimitSell" decimal(28, 8) NULL, -- Лимит необеспеченных продаж КЦ + "UserUncoveredLimitSell" decimal(28, 8) NULL, -- Лимит необеспеченных продаж участника + "Flags" int NULL, -- Параметр счета + + PRIMARY KEY ("SecCode", "FirmId", "DepAccId", "TrdAccId", "LoadDate") +); + + +-- Таблица стоп-заявок +CREATE TABLE "StopOrders" +( + "StopOrderNum" decimal(20, 0) NOT NULL, -- Номер стоп-заявки на сервере QUIK + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "ConditionPrice" decimal(19, 8) NULL, -- Стоп-цена + "LinkedOrder" decimal(20, 0) NULL, -- Номер заявки + "Price" decimal(19, 8) NULL, -- Цена + "AllTradeNum" decimal(20, 0) NULL, -- Сделка условия + "CoOrderNum" decimal(20, 0) NULL, -- Связанная заявка + "BaseOrderNum" decimal(20, 0) NULL, -- Заявка условия + "CoOrderPrice" decimal(19, 8) NULL, -- Цена связанной заявки + "Offset" decimal(19, 6) NULL, -- Отступ от min/max (только для Тейк-профит) + "Spread" decimal(19, 6) NULL, -- Защитный спред (только для Тейк-профит) + "Qty" decimal(28, 8) NULL, -- Количество бумаг, в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг, в штуках + "ClassCode" varchar(12) NULL, -- Код класса + "Balance" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в лотах + "Balance_pcs" decimal(28, 8) NULL, -- Неисполненный остаток заявки, в штуках + "Condition" varchar(12) NULL, -- Условие стоп-заявки + "Expiry" varchar(20) NULL, -- Срок истечения стоп-заявки + "FilledQty" decimal(28, 8) NULL, -- Исполненное количество ценных бумаг, в лотах + "UID" int NULL, -- UID пользователя QUIK, который выставил стоп-заявку + "Account" varchar(12) NULL, -- Торговый счет + "BrokerRef" varchar(34) NULL, -- Комментарий, обычно: <код клиента>/<номер поручения> + "ClientCode" varchar(12) NULL, -- Код клиента + "FirmId" varchar(12) NULL, -- Код участника торгов + "FirmName" varchar(128) NULL, -- Наименование участника торгов + "State" varchar(12) NULL, -- Состояние заявки + "StateDesc" varchar(40) NULL, -- Результат + "OrderKind" varchar(40) NULL, -- Тип стоп-заявки + "Type" varchar(40) NULL, -- Тип заявки + "Mode" varchar(20) NULL, -- Тип заявки, код из нескольких символов + "Operation" varchar(40) NULL, -- Операция + "ConditionSecCode" varchar(12) NULL, -- Код бумаги стоп-цены + "ConditionSecName" varchar(128) NULL, -- Бумага стоп-цены + "ConditionClassCode" varchar(12) NULL, -- Код класса стоп-цены + "ConditionClassName" varchar(128) NULL, -- Класс стоп-цены + "TransId" decimal(20, 0) NULL, -- ID транзакции + "OffsetUnits" varchar(2) NULL, -- Единицы измерения отступа + "SpreadUnits" varchar(2) NULL, -- Единицы измерения спреда + "OwnerServer" varchar(16) NULL, -- Сервер + "ActiveFromTime" timestamp without time zone NULL, -- Время внутри торгового дня, начиная с которого проверяются условия выполнения стоп-заявки + "ActiveToTime" timestamp without time zone NULL, -- Время снятия стоп-заявки + "ConditionPrice2" decimal(19, 8) NULL, -- Цена условия для выставления лимитированной заявки + "ActiveFromToTime" varchar(2) NULL, -- Если «Да», то заданы значения ActiveFromTime и ActiveToTime + "MarketStopLimit" varchar(2) NULL, -- Заявка, выставляемая по «стоп-лимиту» – рыночная + "MarketTakeProfit" varchar(4) NULL, -- Заявка, выставляемая по «тейк-профиту» – рыночная + "Flags" int NULL, -- Набор битовых флагов + "StopOrderType" varchar(40) NULL, -- Тип стоп-заявки + "StopOrderDateTime" timestamp without time zone NULL, -- Дата и время регистрации стоп-заявки на сервере QUIK + "WithdrawDate" timestamp without time zone NULL, -- Дата снятия стоп-заявки + "WithdrawDateTime" timestamp without time zone NULL, -- Дата и время снятия стоп-заявки + "CanceledUserId" decimal(10, 0) NULL, -- Идентификатор пользователя, снявшего заявку + "ActivationDateTime" timestamp without time zone NULL, -- время активации стоп-заявки + "ActivationDateTimeMs" int NULL, -- Микросекунды для времени активации стопзаявки + + PRIMARY KEY ("StopOrderNum", "TradeDate") +); + + +-- Таблица безадресных заявок на внебиржевые сделки +CREATE TABLE "Quotes" +( + "QuoteNum" decimal(20, 0) NOT NULL, -- Номер заявки в торговой системе + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "Operation" varchar(16) NOT NULL, -- Тип операции (продажа или покупка) + + "SecCode" varchar(12) NOT NULL, -- Код бумаги + "Price" decimal(19, 8) NULL, -- Цена за одну ценную бумагу + "Price2" decimal(19, 8) NULL, -- Цена выкупа второй части РЕПО + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО + "RepoValue" decimal(19, 8) NULL, -- Сумма РЕПО + "Repo2Value" decimal(19, 8) NULL, -- Объем выкупа РЕПО + "OriginRepoValue" decimal(19, 8) NULL, -- Сумма РЕПО первоначальная + "Discount" decimal(19, 8) NULL, -- Дисконт + "OriginDiscount" decimal(19, 8) NULL, -- Дисконт (%) первоначальный + "QuoteTime" timestamp without time zone NULL, -- Время регистрации заявки в торговой системе + "Qty" decimal(28, 8) NULL, -- Количество бумаг, в лотах + "Qty_pcs" decimal(28, 8) NULL, -- Количество бумаг, в штуках + "OriginQty" decimal(28, 8) NULL, -- Количество первоначальное, в лотах + "State" varchar(12) NULL, -- Состояние заявки + "RepoEntry" varchar(32) NULL, -- Тип ввода заявки РЕПО + "RepoTerm" smallint NULL, -- Срок РЕПО + "SettleCode" varchar(12) NULL, -- Код расчетов + "UserId" varchar(32) NULL, -- Идентификатор трейдера, подавшего заявку + "FirmId" varchar(12) NULL, -- Код участника торгов + "FirmName" varchar(128) NULL, -- Наименование участника торгов + "TradeSessionDate" timestamp without time zone NULL, -- Дата биржевой торговой сессии + + PRIMARY KEY ("QuoteNum", "TradeDate", "ClassCode", "Operation") +); + + +-- Таблица участников торгов +CREATE TABLE "Firms" +( + "Exchange" varchar(12) NOT NULL, -- Идентификатор биржи + "FirmId" varchar(12) NOT NULL, -- Код участника торгов + + "FirmName" varchar(128) NULL, -- Наименование участника торгов + "Status" varchar(20) NULL, -- Статус фирмы, определяющий возможность совершения торговых операций. Возможные значения: «Активная» либо «Неактивная» + "StatusFlag" int NULL, -- Статус фирмы в виде флага + + PRIMARY KEY ("Exchange", "FirmId") +); + + +-- Настройки Библиотеки расчета лимитов +CREATE TABLE "DealLibs" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "FirmID" varchar(20) NOT NULL, -- Идентификатор участника торгов + + "DealLibText" text NULL, -- Содержимое файла настроек Библиотеки расчета лимитов + "ChangeTime" timestamp without time zone NULL, -- Время обновления записи + + PRIMARY KEY ("TradeDate", "FirmID") +); + + +-- Таблица торговых счетов +CREATE TABLE "Trdaccs" +( + "TrdAccId" varchar(12) NOT NULL, -- Торговый счет + "Type" varchar(64) NOT NULL, -- Тип депозитарного счета + "FirmId" varchar(12) NOT NULL, -- Идентификатор участника торгов + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "Description" varchar(128) NULL, -- Описание + "BankAccId" varchar(12) NULL, -- Код позиции + "Status" int NULL, -- Статус + "DepAccId" varchar(12) NULL, -- Номер счета депо в депозитарии + "TrdAccType" varchar(64) NULL, -- Тип торгового счета + "MainTrdAccId" varchar(12) NULL, -- Основной торговый счет + "FirmUse" smallint NULL, -- Тип раздела + "FullCoveredSell" smallint NULL, -- Запрет необеспеченных продаж + "BankIdT0" varchar(12) NULL, -- Расчетная организация по Т0 + "BankIdTPlus" varchar(12) NULL, -- Расчетная организация по Т+ + "DepUnitId" varchar(17) NULL, -- Раздел счета депо + "Flags" int NULL, -- Флаги на торговом счёте + + PRIMARY KEY ("TrdAccId", "Type", "LoadDate", "FirmId") +); + + +-- Таблица обязательств и требований по активам +CREATE TABLE "CCPHold" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "FirmId" varchar(12) NOT NULL, -- Идентификатор участника торгов + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "Account" varchar(12) NOT NULL, -- Торговый счет + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчетов + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "BankAccId" varchar(12) NOT NULL, -- Код позиции + "PosType" char(1) NOT NULL, -- Тип открытой позиции по активу в дате исполнения + + "DepAccId" varchar(12) NULL, -- Счет депо + "Qty" decimal(28, 8) NULL, -- Количество в сделках + "QtyBuy" decimal(28, 8) NULL, -- Количество в заявках на покупку + "QtySell" decimal(28, 8) NULL, -- Количество в заявках на продажу + "Netto" decimal(19, 2) NULL, -- Сумма в сделках + "Debit" decimal(19, 2) NULL, -- Сумма в заявках на покупку + "Credit" decimal(19, 2) NULL, -- Сумма в заявках на продажу + "PlannedCovered" decimal(19, 2) NULL, -- Плановая позиция Т+ + "PlannedCoveredSellLimit" decimal(19, 2) NULL, -- Лимит обеспеченных продаж + + PRIMARY KEY ("ClassCode", "SecCode", "FirmId", "SettleDate", "Account", "LoadDate", "BankAccId", "PosType") +); + + +-- Таблица параметров процентных рисков +CREATE TABLE "CCPIndicative" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "CCPNum" smallint NOT NULL, -- Дата расчетов + "SettleDate" timestamp without time zone NOT NULL, -- Диапазон + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "QtyBegin" decimal(19, 0) NULL, -- Начало диапазона, шт. + "QtyEnd" decimal(19, 0) NULL, -- Конец диапазона, шт. + "LimitBegin"decimal(19, 2) NULL, -- Начало диапазона, шт. + "LimitEnd" decimal(19, 2) NULL, -- Конец диапазона, шт. + "Discount" decimal(19, 2) NULL, -- Дисконт, % + "RepoLRateRub" decimal(19, 6) NULL, -- Нижняя граница + "RepoCRateRub" decimal(19, 6) NULL, -- Расчетная ставка РЕПО + "RepoHRateRub" decimal(19, 6) NULL, -- Верхняя граница + "RepoLRate" decimal(19, 2) NULL, -- Нижняя граница, % + "RepoCRate" decimal(19, 2) NULL, -- Расчетная ставка РЕПО, % + "RepoHRate" decimal(19, 2) NULL, -- Верхняя граница, % + "QtyBeginRub" decimal(19, 2) NULL, -- Начало диапазона, руб. + "QtyEndRub" decimal(19, 2) NULL, -- Конец диапазона, руб. + "SettlePrice" decimal(19, 8) NULL, -- Расчетная цена с учетом дисконта + "PurePrice" decimal(19, 8) NULL, -- Расчетная цена с НКД + "Discount_L" decimal(19, 3) NULL, -- Ставка риска падения цены, % + "Discount_H" decimal(19, 3) NULL, -- Ставка риска роста цены, % + "ChangeTime" timestamp without time zone NULL, -- Время изменения ставок риска + "BasePoint" int NULL, -- Базовая точка. Значения: 0 = Нет, 1 = Да + + PRIMARY KEY ("ClassCode", "SecCode", "CCPNum", "SettleDate", "LoadDate") +); + + +-- Таблица параметров рыночных рисков +CREATE TABLE "CCPPriceRange" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "CCPNum" smallint NOT NULL, -- Диапазон + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "CurrCode" varchar(12) NOT NULL, -- Код валюты + + "QtyBegin" decimal(19, 0) NULL, -- Начало диапазона, шт. + "QtyEnd" decimal(19, 0) NULL, -- Конец диапазона, шт. + "Discount" decimal(19, 2) NULL, -- Дисконт, % + "RTLRub" decimal(19, 6) NULL, -- Нижняя граница + "RTHRub" decimal(19, 6) NULL, -- Верхняя границ + "QtyBeginRub" decimal(19, 2) NULL, -- Начало диапазона, руб. + "QtyEndRub" decimal(19, 2) NULL, -- Конец диапазона, руб. + "Discount_CCP" decimal(19, 2) NULL, -- Рыночный риск, % + "Discount_L" decimal(19, 3) NULL, -- Ставка риска падения цены, % + "Discount_H" decimal(19, 3) NULL, -- Ставка риска роста цены, % + "SettlePrice" decimal(19, 8) NULL, -- Расчетная цена, руб. + "ChangeTime" timestamp without time zone NULL, -- Время изменения ставок риска + + PRIMARY KEY ("LoadDate", "ClassCode", "SecCode", "CCPNum", "CurrCode") +); + + +-- Обязательства маркетмейкера по срочному рынку +CREATE TABLE "MarketMakerForts" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "DataID" varchar(40) NOT NULL, -- Внутренний идентификатор + + "FirmID" varchar(12) NULL, -- Идентификатор фирмы + "TrdAccID" varchar(12) NULL, -- Торговый счёт + "ClassCode" varchar(12) NULL, -- Код класса + "SecCode" varchar(12) NULL, -- Код инструмента + "MM_SPREAD" decimal(19, 5) NULL, -- Спред по договору + "MM_AMOUNT" decimal(19, 0) NULL, -- Кол-во по договору + "SPREAD" decimal(19, 5) NULL, -- Спред в пунктах + "PERIOD_START_DATE" timestamp without time zone NULL, -- Начало периода действия правил ММ (дата) + "PERIOD_START_TIME" timestamp without time zone NULL, -- Начало периода действия правил ММ (время) + "PERIOD_END_DATE" timestamp without time zone NULL, -- Окончание периода действия правил ММ (дата) + "PERIOD_END_TIME" timestamp without time zone NULL, -- Окончание периода действия правил ММ (время) + "SPREAD_SIGN" int NULL, -- Признак: 1 – спред не держится, 0 – держится + "AMOUNT_SIGN" int NULL, -- Признак: 1 – кол-во не держится, 0 – держится + "PRICE_EDGE_SELL" decimal(19,8) NULL, -- Цена худшей заявки на продажу, вошедшей в спред + "PRICE_EDGE_BUY" decimal(19,8) NULL, -- Цена худшей заявки на покупку, вошедшей в спред + "AMOUNT_SELLS" decimal(19, 0) NULL, -- Кол-во контрактов в заявках на продажу, входящих в спред + "AMOUNT_BUYS" decimal(19, 0) NULL, -- Кол-во контрактов в заявках на покупку, входящих в спред. + "PERCENT_TIME" decimal(19,2) NULL, -- Процент выполнения Обязательств + "CSTRIKE_OFFSET" decimal(19,5) NULL, -- Смещение от центрального страйка (поле действительно только для опционов) + "FULFIL_MIN" decimal(19,2) NULL, -- Процент минимального исполнения обязательств за торговую сессию + "FULFIL_PARTIAL" decimal(19,2) NULL, -- Процент частичного исполнения обязательств за торговую сессию + "FULFIL_TOTAL" decimal(19,2) NULL, -- Процент полного исполнения обязательств за торговую сессию + "IS_FULFIL_MIN" int NULL, -- Признак минимального исполнения обязательств в текущий момент: 1 – исполнен, 0 – не исполнен + "IS_FULFIL_PARTIAL" int NULL, -- Признак частичного исполнения обязательств в текущий момент: 1 – исполнен, 0 – не исполнен + "IS_FULFIL_TOTAL" int NULL, -- Признак полного исполнения обязательств в текущий момент: 1 – исполнен, 0 – не исполнен + "IsClearingRequirement" int NULL, -- Признак обязательства расчетной фирмы + "MMAgreementID" decimal(19,0) NULL, -- Идентификатор обязательства ММ + "AgreementNumber" varchar(50) NULL, -- Номер договора + + PRIMARY KEY ("LoadDate", "DataID") +); + + +-- Расширенные обязательства ММ по фондовому и валютному рынкам +CREATE TABLE "MarketMakerExtStats" +( + "MMID" varchar(12) NOT NULL, -- Идентификатор записи маркет-мейкингав торговой системе + + "ClassCode" varchar(12) NULL, -- Идентификатор режима торгов для финансового инструмента + "SecCode" varchar(12) NULL, -- Идентификатор финансового инструмента + "PARENTMMID" varchar(12) NULL, -- Идентификатор родительской записи маркет-мейкинга в торговой системе + "TRDACCID" varchar(12) NULL, -- Номер торгового счета + "CLIENTCODE" varchar(12) NULL, -- Код клиента, присвоенный брокером + "SCHEMANAME" varchar(20) NULL, -- Наименование схемы маркет-мейкинга + "AGREEMENT" varchar(12) NULL, -- Номер договора с маркет-мейкером + "AGREEMENTDATE" timestamp without time zone NULL, -- Дата подписания договора с маркетмейкером + "MINSPREADVOLUMEBUY" decimal(19,0) NULL, -- Минимально допустимый объем в лотах на покупку для маркет-мейкера + "MINSPREADVALUEBUY" decimal(16,2) NULL, -- Минимально допустимый объем в рублях на покупку для маркет-мейкера + "MINSPREADVOLUMESELL" decimal(19,0) NULL, -- Минимально допустимый объем в лотах на продажу для маркет-мейкера + "MINSPREADVALUESELL" decimal(19,2) NULL, -- Минимально допустимый объем в рублях на продажу для маркет-мейкера + "MAXTRADESVOLUME" decimal(19,0) NULL, -- Установленный объем сделок, по достижении которого изменяется тип котирования с двухстороннего на одностороннее или на освобождение от котирования, в лотах + "MAXTRADESVALUE" decimal(19,2) NULL, -- Установленный объем сделок, по достижении которого изменяется тип котирования с двухстороннего на одностороннее или на освобождение от котирования, в рублях + "MINPASSIVETRADESVALUE" decimal(19,2) NULL, -- Минимальный объем пассивных сделок, руб. + "MAXSPREADPERC" decimal(19,2) NULL, -- Допустимый коридор колебания цены, в процентах + "MAXSPREADPERC1" decimal(19,2) NULL, -- Предельное отклонение цены заявки на покупку от расчетной цены + "MAXSPREADPERC2" decimal(19,2) NULL, -- Предельное отклонение цены заявки на продажу от расчетной цены + "MAXSPREADPRICE" decimal(19,8) NULL, -- Допустимый коридор колебания цены в руб. + "MINKEEPTIME" timestamp without time zone NULL, -- Минимальное время поддержания + "MINKEEPPERCENT" decimal(19,2) NULL, -- Минимальный процент исполнения + "CURRENTSPREADPERC" decimal(19,2) NULL, -- Величина текущего спреда маркетмейкера, в процентах + "CURRENTSPREADPERC1" decimal(19,2) NULL, -- Отклонение цены заявки на покупку от текущей цены + "CURRENTSPREADPERC2" decimal(19,2) NULL, -- Отклонение цены заявки на продажу от текущей цены + "CURRENTSPREADPRICE" decimal(19,8) NULL, -- Величина текущего спреда маркет-мейкера в руб. + "CURRENTMINBUYPRICE" decimal(19,8) NULL, -- Лучшая цена активной лимитированной заявки на покупку + "CURRENTMAXSELLPRICE" decimal(19,8) NULL, -- Лучшая цена активной лимитированной заявки на продажу + "CURRENTALLOWEDBUYPRICE" decimal(19,8) NULL, -- Минимальная допустимая цена активной лимитной заявки на покупку + "CURRENTALLOWEDSELLPRICE" decimal(19,8) NULL, -- Максимальная допустимая цена активной лимитной заявки на продажу + "TOTALBUYVOLUME" decimal(19,0) NULL, -- Объем активных лимитированных заявок на покупку, в лотах + "TOTALSELLVOLUME" decimal(19,0) NULL, -- Объем активных лимитированных заявок на продажу, в лотах + "TOTALBUYVALUE" decimal(19,2) NULL, -- Объем активных лимитированных заявок на покупку, руб. + "TOTALSELLVALUE" decimal(19,2) NULL, -- Объем активных лимитированных заявок на продажу, руб. + "PUBLICPRICE" decimal(19,8) NULL, -- Текущая расчетная цена + "TOTALBREACHTIME" timestamp without time zone NULL, -- Общее время нарушения маркетмейкером своих обязательств по поддержке котировок + "TOTALKEEPTIME" timestamp without time zone NULL, -- Общее время выполнения обязательств по минимально допустимому объему и спреду + "TOTALKEEPTIME1" timestamp without time zone NULL, -- Фактическое время поддержания заявок на покупку + "TOTALKEEPTIME2" timestamp without time zone NULL, -- Фактическое время поддержания заявок на продажу + "FACTTRADESVOLUME" decimal(19,0) NULL, -- Текущий объем заключенных сделок, в лотах + "FACTTRADESVALUE" decimal(19,2) NULL, -- Текущий объем заключенных сделок, руб. + "FACTPASSIVETRADESVOLUME" decimal(19,0) NULL, -- Текущий объем заключенных пассивных сделок, в лотах + "FACTPASSIVETRADESVALUE" decimal(19,2) NULL, -- Текущий объем заключенных пассивных сделок, руб. + "QUOTETYPE" varchar(16) NULL, -- Тип котирования в текущий момент + "FACTKEEPPERCENT" decimal(19,2) NULL, -- Процент исполнения + "FACTKEEPPERCENT1" decimal(19,2) NULL, -- Процент исполнения на покупку + "FACTKEEPPERCENT2" decimal(19,2) NULL, -- Процент исполнения на продажу + "LEFTKEEPTIME" timestamp without time zone NULL, -- Оставшееся время до исполнения обязательств + "UPDATETIME" timestamp without time zone NULL, -- Время обновления + "PROCESSED" varchar(3) NULL, -- Обязательства исполнены + "KEEPSPREADVARIANT" varchar(4) NULL, -- Тип поддерживаемого спреда + "INDICATOR1" decimal(19,8) NULL, -- Фикс. ставка РЕПО ЦБ ON, % AddDouble + "INDICATOR2" decimal(19,8) NULL, -- Ставка RuRepo ON, % AddDouble + "INDICATOR3" decimal(19,8) NULL, -- Ставка MosPrime ON, % AddDouble + "MINSHAREISSUES" int NULL, -- Минимальное обязательство по количеству выпусков ценных бумаг + "COMPLETESHAREISSUES" int NULL, -- Выполнено по количеству выпусков ценных бумаг + + PRIMARY KEY ("MMID") +); + + +-- Обязательства и требования по активам (расширенная) [ЦК] +CREATE TABLE "RmHoldDetl" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "FirmId" varchar(12) NOT NULL, -- Идентификатор фирмы: «U» – раздел обеспечения; значения, отличные от «U» - для торговых разделов + "Account" varchar(12) NOT NULL, -- Торговый счет + "BankAccId" varchar(12) NOT NULL, -- Код позиции + "ClassCode1" varchar(12) NOT NULL, -- Код класса актива 1 (инструмент) + "Asset1" varchar(12) NOT NULL, -- Код бумаги актива 1 (инструмент) + "ClassCode2" varchar(12) NOT NULL, -- Код класса актива 2 (для расчетов) + "Asset2" varchar(12) NOT NULL, -- Код бумаги актива 2 (для расчетов) + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчетов + + "DepAccId" varchar(12) NULL, -- Счет депо + "FirmUse" varchar(1) NULL, -- Тип раздела + "Asset1CreditBalance" decimal(19, 2) NULL, -- Входящие требования (актив 1). (Точность 2) + "Asset1DebitBalance" decimal(19, 2) NULL, -- Входящие обязательства (актив 1). (Точность 2) + "Asset1Value" decimal(19, 2) NULL, -- Нетто (актив 1). (Точность 2) + "Asset1ValueCredit" decimal(19, 2) NULL, -- Требования (актив 1). (Точность 2) + "Asset1ValueDebit" decimal(19, 2) NULL, -- Обязательства (актив 1). (Точность 2) + "Asset1ValueBuy" decimal(19, 2) NULL, -- Требования в заявках (актив 1). (Точность 2) + "Asset1ValueSell" decimal(19, 2) NULL, -- Обязательства в заявках (актив 1). (Точность 2) + "Asset2CreditBalance" decimal(19, 2) NULL, -- Входящие требования (актив 2). (Точность 2) + "Asset2DebitBalance" decimal(19, 2) NULL, -- Входящие обязательства (актив 2). (Точность 2) + "Asset2Value" decimal(19, 2) NULL, -- Нетто (актив 2). (Точность 2) + "Asset2ValueCredit" decimal(19, 2) NULL, -- Требования (актив 2). (Точность 2) + "Asset2ValueDebit" decimal(19, 2) NULL, -- Обязательства (актив 2). (Точность 2) + "Asset2ValueBuy" decimal(19, 2) NULL, -- Требования в заявках (актив 2). (Точность 2) + "Asset2ValueSell" decimal(19, 2) NULL, -- Обязательства в заявках (актив 2). (Точность 2) + "PlannedCovered" decimal(19, 2) NULL, -- Плановая Т+ (Точность 2) + "PlannedCoveredSellLimit" decimal(19, 2) NULL, -- Лимит обеспеченных продаж (Точность 2) + + PRIMARY KEY ("TradeDate", "FirmId", "Account", "BankAccId", "ClassCode1", "Asset1", "ClassCode2", "Asset2", "SettleDate") +); + + +-- Валюта: параметры процентных рисков +CREATE TABLE "RmIndicative" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса актива + "SecCode" varchar(12) NOT NULL, -- Код актива + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчетов + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "Num" int NOT NULL, -- Номер диапазона + + "LimitBegin" decimal(19, 2) NULL, -- Начало диапазона, шт + "LimitEnd" decimal(19, 2) NULL, -- Конец диапазона, шт + "LRateRub" decimal(19, 6) NULL, -- Нижняя граница, руб + "CRateRub" decimal(19, 6) NULL, -- Расчетная ставка РЕПО, руб + "HRateRub" decimal(19, 6) NULL, -- Верхняя граница, руб + "LimitBeginRub" decimal(19, 2) NULL, -- Начало диапазона, руб + "LimitEndRub" decimal(19, 2) NULL, -- Конец диапазона, руб + "ChangeTime" timestamp without time zone NULL, -- Время изменения ставок риска + "RcRub" decimal(19, 8) NULL, -- Расчетная цена, руб. + "BasePoint" int NULL, -- Признак «Базовая точка» + "DeltaRCL" decimal(19,8) NULL, -- Поправка нижнего ценового коридора маржирования рыночных заявок + "DeltaRCH" decimal(19,8) NULL, -- Поправка верхнего ценового коридора маржирования рыночных заявок + + PRIMARY KEY ("ClassCode", "SecCode", "SettleDate", "LoadDate", "Num") +); + + +-- Валюта: параметры рыночных рисков +CREATE TABLE "RmPriceRange" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "Num" int NOT NULL, -- Номер диапазона + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "CurrCode" varchar(12) NOT NULL, -- Код валюты + + "LimitBegin" decimal(19, 2) NULL, -- Начало диапазона + "LimitEnd" decimal(19, 2) NULL, -- Конец диапазона + "RTLRub" decimal(19, 6) NULL, -- Нижняя граница курса + "RTHRub" decimal(19, 6) NULL, -- Верхняя граница курса + "LimitBeginRub" decimal(19, 2) NULL, -- Начало диапазона, руб. + "LimitEndRub" decimal(19, 2) NULL, -- Конец диапазона, руб. + "SettlePrice" decimal(19, 8) NULL, -- Центральный курс + "ChangeTime" timestamp without time zone NULL, -- Время изменения ставок риска + "Discount_L" decimal(19, 3) NULL, -- Ставка риска падения курса, % + "Discount_H" decimal(19, 3) NULL, -- Ставка риска роста курса, % + + PRIMARY KEY ("ClassCode", "SecCode", "Num", "LoadDate", "CurrCode") +); + + +--Валюта: индивидуальные риск параметры +CREATE TABLE "RmPriceRangeFirm" +( + "FirmId" varchar(12) NOT NULL, -- Идентификатор фирмы + "Account" varchar(12) NOT NULL, -- Торговый счет + "BankAccId" varchar(12) NOT NULL, -- Идентификатор расчетного счета в НКЦ + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "K_EXCH" decimal(19, 2) NULL, -- Коэффициент национального клирингового центра (НКЦ) + "K_EXCH_SET" decimal(19, 2) NULL, -- Коэффициент национального клирингового центра (НКЦ) на завтра + "K_USER" decimal(19, 2) NULL, -- Коэффициент пользователя + "K_USER_SET" decimal(19, 2) NULL, -- Коэффициент пользователя на завтра + "COLLATERAL" varchar(3) NULL, -- Прием в обеспечение + + PRIMARY KEY("FirmId", "Account", "BankAccId", "ClassCode", "SecCode", "LoadDate") +); + + +-- НКД на каждую дату расчетов по облигациям +CREATE TABLE "SecAccIntEx" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "SecCode" varchar(12) NOT NULL, -- Код инструмента + "CurrCode" varchar(12) NOT NULL, -- Код валюты + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчётов + + "AccInt" decimal(19, 8) NULL, -- Объем НКД (процентный доход) + "FaceValue" decimal(19, 8) NULL, -- Номинал (в валюте инструмента) + + PRIMARY KEY ("LoadDate", "SecCode", "CurrCode", "SettleDate") +); + + +-- Таблица алгоритмических заявок +CREATE TABLE "AlgoOrders" +( + "OrderNum" decimal(20, 0) NOT NULL, -- Номер алго-заявки + "ClassCode" varchar(12) NOT NULL, -- Код класса алго-заявки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "order_date" timestamp without time zone NULL, -- Дата регистрации + "order_time" timestamp without time zone NULL, -- Время регистрации + "order_time_mcs" int NULL, -- Время регистрации (мкс) + "kill_date" timestamp without time zone NULL, -- Дата снятия + "kill_time" timestamp without time zone NULL, -- Время снятия + "kill_time_mcs" int NULL, -- Время снятия (мкс) + "class_code" varchar(12) NULL, -- Код класса + "class_name" varchar(255) NULL, -- Наименование класса + "sec_code" varchar(12) NULL, -- Код бумаги + "sec_name" varchar(32) NULL, -- Бумага сокр + "sec_fullname" varchar(128) NULL, -- Бумага + "buysell" smallint NULL, -- Операция + "mktlimit" smallint NULL, -- Тип + "account" varchar(12) NULL, -- Торговый счет + "algo_firm_code" varchar(12) NULL, -- Идентификатор фирмы + "firmid" varchar(12) NULL, -- Фирма связанных заявок + "qty" decimal(19, 0) NULL, -- Количество + "balance" decimal(19, 0) NULL, -- Остаток + "qty_exec" decimal(19, 0) NULL, -- Исполненное количество + "value_exec" decimal(19, 2) NULL, -- Исполненный объем + "exec_percent" decimal(19, 8) NULL, -- Процент исполнения + "price" decimal(19, 8) NULL, -- Цена + "value" decimal(19, 2) NULL, -- Объем + "vwap" decimal(19, 8) NULL, -- Средневзвес исполнения + "vwap_price" decimal(19, 8) NULL, -- Средневзвес исполнения (цена) + "uid" int NULL, -- Пользователь + "client" varchar(12) NULL, -- Клиент + "memo" varchar(34) NULL, -- Комментарий + "status" smallint NULL, -- Статус + "cancel_reason" smallint NULL, -- Причина снятия + "settle_code" varchar(12) NULL, -- Код расчетов + "time_in_force" smallint NULL, -- Срок действия + "expiry_date" timestamp without time zone NULL, -- Дата истечения + "expiry_time" timestamp without time zone NULL, -- Время истечения + "last_trade_price" decimal(19, 8) NULL, -- Цена последней сделки + "cur_session" smallint NULL, -- Текущая сессия + "enable_exec_alert" smallint NULL, -- Оповещения об исполнениях + "use_work_interval" smallint NULL, -- Использовать временной интервал + "start_time" timestamp without time zone NULL, -- Время старта + "stop_time" timestamp without time zone NULL, -- Время окончания + "cp_firmid" varchar(12) NULL, -- Фирма партнера связанных заявок + "displayedqty" decimal(19,0) NULL, -- Видимое количество + "cancel_flag" smallint NULL, -- Снимать по связанной + "aggressive" smallint NULL, -- Агрессивность + "spread" decimal(19,8) NULL, -- Спред + "qty_balance" decimal(19,0) NULL, -- Остаток (количество) + "value_balance" decimal(19,2) NULL, -- Остаток (объем) + "qty_value" smallint NULL, -- Количество/Объем + "currency" varchar(12) NULL, -- Валюта + "class_code_buy" varchar(12) NULL, -- Код класса инструмента (покупка) + "class_name_buy" varchar(255) NULL, -- Наименование класса инструмента (покупка) + "sec_code_buy" varchar(12) NULL, -- Код инструмента (покупка) + "sec_name_buy" varchar(32) NULL, -- Наименование инструмента (покупка) + "sec_fullname_buy" varchar(128) NULL, -- Длинное наименование инструмента (покупка) + "account_buy" varchar(12) NULL, -- Торговый счет (покупка) + "firmid_buy" varchar(12) NULL, -- Фирма связанных заявок (покупка) + "client_buy" varchar(12) NULL, -- Код клиента (покупка) + "class_code_sell" varchar(12) NULL, -- Код класса инструмента (продажа) + "class_name_sell" varchar(255) NULL, -- Наименование класса инструмента (продажа) + "sec_code_sell" varchar(12) NULL, -- Код инструмента (продажа) + "sec_name_sell" varchar(32) NULL, -- Наименование инструмента (продажа) + "sec_fullname_sell" varchar(128) NULL, -- Длинное наименование инструмента (продажа) + "account_sell" varchar(12) NULL, -- Торговый счет (продажа) + "firmid_sell" varchar(12) NULL, -- Фирма связанных заявок (продажа) + "client_sell" varchar(12) NULL, -- Код клиента (продажа) + "spread_type" smallint NULL, -- Тип защитного спреда + "iter_qty" decimal(19,0) NULL, -- Количество в итерации + "iter_value" decimal(19, 2) NULL, -- Объем в итерации + "value_balance_buy" decimal(19, 2) NULL, -- Остаток объема (покупка) + "qty_exec_buy" decimal(19,0) NULL, -- Исполненное количество (покупка) + "value_exec_buy" decimal(19, 2) NULL, -- Исполненный объем (покупка) + "vwap_buy" float NULL, -- Средневзвес исполнения (покупка) + "vwap_price_buy" float NULL, -- Средневзвес исполнения (цена,покупка) + "value_balance_sell" decimal(19, 2) NULL, -- Остаток объема (продажа) + "qty_exec_sell" decimal(19,0) NULL, -- Исполненное количество (продажа) + "value_exec_sell" decimal(19,2) NULL, -- Исполненный объем (продажа) + "vwap_sell" float NULL, -- Средневзвес исполнения (продажа) + "vwap_price_sell" float NULL, -- Средневзвес исполнения (цена,продажа) + "iter_time" int NULL, -- Время итерации + "stop_limit" decimal(19, 8) NULL, -- Стоп-лимит + "take_profit" decimal(19, 8) NULL, -- Тейк-профит + "spread_percent" decimal(19, 8) NULL, -- Спред в процентах + "stop_limit_percent" decimal(19, 8) NULL, -- Стоп-лимит в процентах + "take_profit_percent" decimal(19, 8) NULL, -- Тейк-профит в процентах + "limit_kind" smallint NULL, -- Тип лимита + "cancel_client_orders" smallint NULL, -- Снимать клиентские заявки + "stop_limit_type" smallint NULL, -- Тип стоп-лимита + "take_profit_type" smallint NULL, -- Тип тейк-профита + "limit_type" smallint NULL, -- Тип ограничения цены + "limit" float NULL, -- Ограничение цены, % + "limit_money" decimal(19, 8) NULL, -- Ограничение цены, деньги + "iter_numb_set_mode" smallint NULL, -- Тип задания интервала + "number" int NULL, -- Количество итераций + "current" int NULL, -- Номер текущей итерации + "time_type" smallint NULL, -- Тип задания времени старта + "use_time_profile" smallint NULL, -- Профилирование по времени + "volatility" float NULL, -- Волатильность + "volatility_var" float NULL, -- Допустимое отклонение волатильности + "volatility_var_type" smallint NULL, -- Тип задания отклонения волатильности + "need_market_percent" smallint NULL, -- Исполнение рыночного объема + "value_percent" float NULL, -- Процент рыночного объема + "vwap_limit" float NULL, -- Max отклон. от рын. средневзвеса + "vwap_limit_cur" float NULL, -- Тек. отклон. от рын. средневзвеса + "no_stop_time" smallint NULL, -- До закрытия + "value_market" float NULL, -- Рыночный объем + "qty_market" decimal(19,0) NULL, -- Рыночное количество + "vwap_market" float NULL, -- Средневзвес рыночный + "min_price" float NULL, -- Минимальная цена + "max_price" float NULL, -- Максимальная цена + "enable_catch_up" smallint NULL, -- Catch-up заявка + "catch_up_percent" float NULL, -- Процент включения агрессивности + "tick_up" int NULL, -- Степень агрессивности + "use_ioc" smallint NULL, -- Использовать IOC-заявки + "use_iter_limit" smallint NULL, -- Лимитирование внутри итераций + "work_in_price_range" smallint NULL, -- Работа в ценовом диапазоне + "order_state" varchar(3) NULL, -- Состояние + "include_own_trades" smallint NULL, -- Учитывать собственные сделки + "on_behalf_of_uid" int NULL, -- Лицо, от имени которого и за чей счет регистрируется сделка + "exec_type" int NULL, -- Способ исполнения + "settle_date" timestamp without time zone NULL, -- Дата расчетов + "accrued_int" decimal(28,8) NULL, -- НКД + "error_text" varchar(256) NULL, -- Текст ошибки + "mifid2_client_qualifier" int NULL, -- Client qualifier (MiFID II) + "mifid2_client_short_code" varchar(12) NULL, -- Client short code (MiFID II) + "algorithm_name" varchar(32) NULL, -- Алгоритм + "last_trade_date_time" timestamp without time zone NULL, -- Дата и время последней сделки + "new_order_trans_info" int NULL, -- Info-флаг транзакции для регистрации заявки такого типа + "replace_order_trans_info" int NULL, -- Info-флаг транзакции для замены заявки такого типа + "investment_decision_maker_qualifier" int NULL, -- Квалификатор принявшего решение о совершении сделки (в соответствии с директивой MiFID II) + "mifid2_investment_decision_maker_id" int NULL, -- Краткий идентификатор принявшего решение о совершении сделки (в соответствии с директивой MiFID II) + "executing_trader_qualifier" int NULL, -- Квалификатор исполнившего заявку, по которой совершена сделка (в соответствиис директивой MiFID II) + "mifid2_executing_trader_id" varchar(12) NULL, -- Краткий идентификатор исполнившего заявку, по которой совершена сделка (в соответствии с директивой MiFID II) + "min_pctvolume" decimal(28,8) NULL, -- Минимальный объем + "max_pctvolume_ca" decimal(28,8) NULL, -- Максимальный объем рынка на аукционе закрытия + "use_mtf_volume" int NULL, -- Признак использования финансовой площадки MTF для отслеживания объема + "tick_rule" int NULL, -- Разрешить заявке создавать новые правила торговых ограничений при исполнении + "fixed_price" decimal(28,8) NULL, -- Искать цену + "fixed_price_dark" decimal(28,8) NULL, -- Искать Dark-цену + "fixed_order_pct" int NULL, -- Искать процент заявки + "fixed_order_pctdark" int NULL, -- Искать Dark-процент заявки + "routing_instruction" int NULL, -- Размещение заявки + "target_pov" int NULL, -- Процент от объема + "target_pov_ca" int NULL, -- Процент от объема на аукционе закрытия + "targetpov_bdark" int NULL, -- Процент от объема для Block Trading + "urgency_level" int NULL, -- Уровень важности + "strat_config" varchar(32) NULL, -- Конфигурация стратегий + "minqty" decimal(28,8) NULL, -- Минимальное количество + "qty_bdark" decimal(28,8) NULL, -- Количество для Block Trading + "minqty_bdark" decimal(28,8) NULL, -- Минимальное количество для Block Trading + "price_bdark" decimal(28,8) NULL, -- Цена для Block Trading + "get_done_price" decimal(28,8) NULL, -- Цена по окончанию (Get it done цена) + "max_pctvolume" decimal(28,8) NULL, -- Максимальный объем рынка + "work_at_opening" int NULL, -- Флаг работы алгоритма на открытии + "work_at_closing" int NULL, -- Флаг работы алгоритма на закрытии + "show_hi_touch" int NULL, -- Признак отображения информации о заявке сейлзтрейдерам для дальнейшего исполнения + "pegged" int NULL, -- Отметить заявку как привязанную + "closing_auct" int NULL, -- Исполнение в аукцион закрытия + "target_volume" int NULL, -- Целевой объем + "floating_reference" int NULL, -- Относительная цена + "passive_order" int NULL, -- Признак пассивной заявки + "strategy_style" int NULL, -- Тип стратегии + "exch_order_num" varchar(64) NULL, -- Биржевой номер заявки + "prev_order_num" decimal(20, 0) NULL, -- Номер предыдущей заявки (предыдущая ревизия данной заявки) + "new_order_num" decimal(20, 0) NULL, -- Номер следующей заявки (следующая ревизия данной заявки) + "ext_order_flags" decimal(19,0) NULL, -- Расширенные флаги на заявке + "revision_num" int NULL, -- Номер ревизии заявки + "strat_start_date" timestamp without time zone NULL, -- Дата старта стратегии + "strat_end_date" timestamp without time zone NULL, -- Дата завершения стратегии + "participate_open" int NULL, -- Участие в аукционе открытия + "participate_close" int NULL, -- Участие в аукционе закрытия + "rule80" int NULL, -- Источник заявки + "rule10b18" int NULL, -- Признак применения правила 10-b18 + "targetpov" decimal(28,8) NULL, -- Процент от объема + "price_entry_mode" int NULL, -- Тип ввода цены + "exec_inst" int NULL, -- Тип исполнения заявки + "auction_type" int NULL, -- Тип аукциона + "complete" int NULL, -- Признак завершенности + "rel_limit_bench" int NULL, -- Rel limit bench + "rel_limit_offset" decimal(28,8) NULL, -- Rel limit offset + "country" int NULL, -- Страна (Country) + "benchmark_type" int NULL, -- Тип опорной отметки + "benchmark_price" decimal(28,8) NULL, -- Цена опорной отметки + "execution_view" int NULL, -- Вид исполнения + "auto_display_size" int NULL, -- Автоотображение размера + "aggr_offset_frm_last" decimal(28,8) NULL, -- Aggressive offset from last + "auction_limit" decimal(28,8) NULL, -- Лимит аукциона + "duration_limit" decimal(28,8) NULL, -- Лимит продолжительности + "participation_rate" decimal(28,8) NULL, -- Коэффициент участия + "hidden" int NULL, -- Видимость + "peg_type" int NULL, -- Тип привязки заявки + + PRIMARY KEY ("OrderNum", "ClassCode", "TradeDate") +); + + +-- Связанные заявки +CREATE TABLE "AlgoLinkedOrders" +( + "OrderNum" decimal(20, 0) NOT NULL, -- Номер алго-заявки + "ClassCode" varchar(12) NOT NULL, -- Код класса алго-заявки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "LinkedOrderNum" decimal(20, 0) NOT NULL, -- Номер связанной заявки + "LinkedOrderClassCode" varchar(12) NOT NULL, -- Код класса связанной заявки + "OrderState" varchar(3) NULL, -- VWAP–статус, трехсимвольный код алго-заявки типа «VWAP» + + PRIMARY KEY ("OrderNum", "ClassCode", "TradeDate", "LinkedOrderNum", "LinkedOrderClassCode") +); + + +-- Связанные сделки +CREATE TABLE "AlgoLinkedTrades" +( + "OrderNum" decimal(20, 0) NOT NULL, -- Номер алго-заявки + "ClassCode" varchar(12) NOT NULL, -- Код класса алго-заявки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "LinkedTradeNum" decimal(20, 0) NOT NULL, -- Номер связанной сделки + "LinkedTradeClassCode" varchar(12) NOT NULL, -- Код класса связанной сделки + "LinkedTradeSide" char(1) NOT NULL, -- Направление связанной сделки + + PRIMARY KEY ("OrderNum", "ClassCode", "TradeDate", "LinkedTradeNum", "LinkedTradeClassCode", "LinkedTradeSide") +); + + +-- Облегченные описания инструментов +CREATE TABLE "SecuritiesDictionary" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса инструментов + "SecCode" varchar(12) NOT NULL, -- Код инструмента + + "ISIN" varchar(12) NULL, -- Код ISIN + "StockCode" varchar(64) NULL, -- StockCode + "RIC" varchar(35) NULL, -- RIC-код + "CUSIP" varchar(12) NULL, -- Код северо-американских финансовых инструментов + "SEDOL" varchar(12) NULL, -- Код SEDOL + "RegNumber" varchar(30) NULL, -- Номер гос. регистрации выпуска ценных бумаг + "LocalCode" varchar(20) NULL, -- Локальный код инструмента на бирже + "StockName" varchar(64) NULL, -- Код деривативного контракта в формате QUIK + "SecShortName" varchar(32) NULL, -- Короткое наименование инструмента + "FaceUnit" varchar(12) NULL, -- Валюта номинала + "SecFullName" varchar(128) NULL, -- Полное наименование инструмента + "BSID" varchar(20) NULL, -- Идентификатор Bloomberg security identifier + + PRIMARY KEY ("ClassCode", "SecCode", "TradeDate") +); + + +-- Таблица результатов выполнения транзакций +CREATE TABLE "TransResult" +( + "TransNum" int NOT NULL, -- Уникальный идентификатор транзакции + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "TransDateTime" timestamp without time zone NULL, -- Дата и время подачи транзакции + "TransDateTimeMs" int NULL, -- Микросекунды для времени подачи транзакции + "GateReplyTime" timestamp without time zone NULL, -- Время получения ответа из шлюза к торговой системе + "GateReplyTimeMs" int NULL, -- Микросекунды для времени получения торговой системой ответа из Интерфейса + "AppSentTime" timestamp without time zone NULL, -- Локальное время подачи транзакции, приведенное в UTC + "AppSentTimeMs" int NULL, -- Микросекунды для времени подачи транзакции + "ExporterGotTime" timestamp without time zone NULL, -- Локальное время получения МЭБИ ответа на транзакцию, приведенное в UTC + "ExporterGotTimeMs" int NULL, -- Микросекунды для времени получения МЭБИ ответа на транзакцию + "BaseTransNum" int NULL, -- Идентификатор исходной транзакции (только если данная транзакция является транзакцией контроля дополнительных ограничений) + "RequestId" decimal(20, 0) NULL, -- Внутренний идентификатор транзакции. Используется для внутренних целей и не может служить для целей идентификации конкретной транзакции + "TransType" int NULL, -- Тип транзакции. Используется для внутренних целей и не может служить для целей идентификации конкретной транзакции + "ClassCode" varchar(12) NULL, -- Код класса + "Status" int NULL, -- Статус транзакции + "FirmName" varchar(128) NULL, -- Организация трейдера + "ResultMsg" varchar(2000) NULL, -- Текст ответа на транзакцию + "UID" int NULL, -- Идентификатор пользователя Quik + "ErrorCode" int NULL, -- Числовой код ошибки выполнения транзакции. Равен 0 если транзакция выполнена успешно. В противном случае код ошибки зависит от конкретной торговой системы + "ErrorSource" int NULL, -- Источник ошиб + "Order1SecCode" varchar(12) NULL, -- Код инструмента первой заявки + "Order1FirstNum" decimal(20, 0) NULL, -- Номер первой заявки, которая выставлена при автоматической замене кода клиента + "Order1Num" decimal(20, 0) NULL, -- Номер первой заявки + "Order1Price" decimal(19, 8) NULL, -- Цена первой заявки + "Order1Qty" decimal(28, 8) NULL, -- Количество первой заявки + "Order1Balance" decimal(28, 8) NULL, -- Неисполненный остаток первой заявки + "Order1FirmId" varchar(12) NULL, -- Дилер (код фирмы) первой заявки + "Order1Account" varchar(12) NULL, -- Торговый счет первой заявки + "Order1ClientCode" varchar(12) NULL, -- Код клиента первой заявки + "Order1Brokerref" varchar(34) NULL, -- Комментарий первой заявки + "Order1ExchangeCode" varchar(12) NULL, -- Код биржи первой заявки + "Order1Flags" int NULL, -- Набор битовых флагов, описывающих транзакцию, первой заяв + "Order2SecCode" varchar(12) NULL, -- Код инструмента второй заявки + "Order2FirstNum" decimal(20, 0) NULL, -- Номер первой заявки, которая выставлена при автоматической замене кода клиента + "Order2Num" decimal(20, 0) NULL, -- Номер второй заявки + "Order2Price" decimal(19, 8) NULL, -- Цена второй заявки + "Order2Qty" decimal(28, 8) NULL, -- Количество второй заявки + "Order2Balance" decimal(28, 8) NULL, -- Неисполненный остаток второй заявки + "Order2FirmId" varchar(12) NULL, -- Дилер (код фирмы) второй заявки + "Order2Account" varchar(12) NULL, -- Торговый счет второй заявки + "Order2ClientCode" varchar(12) NULL, -- Код клиента второй заявки + "Order2Brokerref" varchar(34) NULL, -- Комментарий второй заявки + "Order2ExchangeCode" varchar(12) NULL, -- Код биржи второй заявки + "Order2Flags" int NULL, -- Набор битовых флагов, описывающих транзакцию, второй заявки + + PRIMARY KEY ("TransNum", "TradeDate") +); + + +-- Коды расчетов +CREATE TABLE "SettleCodes" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SettleCode" varchar(12) NOT NULL, -- Код расчётов + "FirmID" varchar(12) NOT NULL, -- Дилер (код фирмы) + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "Description" varchar(30) NULL, -- Описание + "SettleDate" timestamp without time zone NULL, -- Дата расчётов + + PRIMARY KEY ("ClassCode", "SettleCode", "FirmID", "TradeDate") +); + + +-- Переводы НКЦ +CREATE TABLE "NCCTransfers" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "TradeNum" decimal(20, 0) NOT NULL, -- Номер сделки + "OrderNum" decimal(20, 0) NOT NULL, -- Номер заявки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "SecCode" varchar(12) NULL, -- Код инструмента + "FirmID" varchar(12) NULL, -- Идентификатор фирмы + "CPFirmId" varchar(12) NULL, -- Идентификатор фирмы-партнера + "TradeDateTime" timestamp without time zone NULL, -- Дата и время создания сделки + "Sell" smallint NULL, -- Направление: 0 = купля, 1 = продажа + "BrokerRef" varchar(34) NULL, -- Примечание + "Account" varchar(12) NULL, -- Код торгового счета + "Price" decimal(19, 8) NULL, -- Цена + "Qty" decimal(19, 0) NULL, -- Объем сделки, выраженный в лотах + "Value" decimal(19, 2) NULL, -- Объем сделки, выраженный в валюте расчетов + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов + "Accruedint" decimal(19, 2) NULL, -- Накопленный купонный доход в валюте расчетов + "SettleCode" varchar(12) NULL, -- Код расчетов + "SettleDate" timestamp without time zone NULL, -- Дата расчетов + "Kind" smallint NULL, -- Вид сделки (тип 4) + "SystemRef" varchar(12) NULL, -- Системная ссылка + "BankAccId" varchar(12) NULL, -- Код позиции + "ClearingFirmId" varchar(12) NULL, -- Клиринговая фирма + "ClearingBankAccId" varchar(12) NULL, -- Клиринговый расчетный код + "TranType" varchar(4) NULL, -- Тип перевода + "TranInfo" varchar(210) NULL, -- Информация о переводе + "Status" smallint NULL, -- Статус сделки + + PRIMARY KEY ("ClassCode", "TradeNum", "OrderNum", "TradeDate") +); + + +-- Запрет на операции +CREATE TABLE "Prohibitions" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "ContractType" smallint NOT NULL, -- Тип контрактов: 1 – фьючерсы, 2 – опционы + "RuleNumber" decimal(20, 0) NOT NULL, -- Номер правила + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + + "RuleType" smallint NULL, -- Тип правила: 1 – глобальное правило, 2 – правило на инструмент + "SecCode" varchar(12) NULL, -- Код инструмента или базового актива. Для глобальных правил: пустая строка. + "BaseSecCode" varchar(12) NULL, -- Код базового актива (только для правил по базовым активам). Для прочих типов правил: пустая строка. + "Section" varchar(50) NULL, -- Секция + "TrdAccId" varchar(12) NULL, -- Код торгового счета + "Priority" int NULL, -- Приоритет правила + "ProhibitionType" int NULL, -- Тип состояния запрета + "Status" int NULL, -- Статус правила: 0 – не разрешено, 1 – разрешено + "Initiator" int NULL, -- Инициатор запрета + "InitiatorType" int NULL, -- Тип инициатора запрета + + PRIMARY KEY ("TradeDate", "FirmID", "ContractType", "RuleNumber") +); + + +-- Данные о точностях валют +CREATE TABLE "CurrencyInfo" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "CurrCode" varchar(12) NOT NULL, -- Код валюты + + "CurrShortName" varchar(32) NULL, -- Краткое наименование валюты + "CurrFullName" varchar(128) NULL, -- Полное наименование валюты + "Unicode" int NULL, -- Юникод валюты, только если валюта является главной (иначе — 0) + "HtmlCode" varchar(12) NULL, -- HTML-код валюты, только если валюта является главной (иначе — пустая строка) + "QtyScale" int NULL, -- Точность количества для данной валюты + "MainCurrencyFlag" int NULL, -- Признак главной валюты (1 – валюта является главной, 0 – не является). + + PRIMARY KEY ("TradeDate", "CurrCode") +); + + +-- Обязательства и требований по деньгам ФР МБ +CREATE TABLE "RmPosnFond" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "FirmId" varchar(12) NOT NULL, -- Идентификатор фирмы + "Asset" varchar(12) NOT NULL, -- Код бумаги + "BankAccId" varchar(12) NOT NULL, -- Код позиции + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчетов + "PosType" char(1) NOT NULL, -- Тип открытой позиции по активу в дате исполнения + + "CreditBalance" decimal(19,2) NULL, -- Входящая по требованиям (Требования на начало дня) + "DebitBalance" decimal(19,2) NULL, -- Входящая по обязательствам (Обязательства на начало дня) + "Value" decimal(19, 2) NULL, -- Нетто + "ValueDebit" decimal(19, 2) NULL, -- Требования + "ValueCredit" decimal(19, 2) NULL, -- Обязательства + "ValueBuy" decimal(19, 2) NULL, -- В заявках на покупку + "ValueSell" decimal(19, 2) NULL, -- В заявках на продажу + "PlannedCovered" decimal(19, 2) NULL, -- Плановая Т+ + "PlannedCoveredSellLimit" decimal(19, 2) NULL, -- Лимит обеспеченных продаж + + PRIMARY KEY ("LoadDate", "ClassCode", "FirmId", "Asset", "BankAccId", "SettleDate", "PosType") +); + + +-- Обязательства и требований по деньгам ФР МБ +CREATE TABLE "RmPosnCurr" +( + "LoadDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "FirmId" varchar(12) NOT NULL, -- Идентификатор фирмы + "Asset" varchar(12) NOT NULL, -- Код бумаги + "BankAccId" varchar(12) NOT NULL, -- Код позиции + "SettleDate" timestamp without time zone NOT NULL, -- Дата расчетов + "PosType" char(1) NOT NULL, -- Тип открытой позиции по активу в дате исполнения + + "CreditBalance" decimal(19,2) NULL, -- Входящая по требованиям (Требования на начало дня) + "DebitBalance" decimal(19,2) NULL, -- Входящая по обязательствам (Обязательства на начало дня) + "Value" decimal(19, 2) NULL, -- Нетто + "ValueDebit" decimal(19, 2) NULL, -- Требования + "ValueCredit" decimal(19, 2) NULL, -- Обязательства + "ValueBuy" decimal(19, 2) NULL, -- В заявках на покупку + "ValueSell" decimal(19, 2) NULL, -- В заявках на продажу + "PlannedCovered" decimal(19, 2) NULL, -- Плановая Т+ + "PlannedCoveredSellLimit" decimal(19, 2) NULL, -- Лимит обеспеченных продаж + + PRIMARY KEY ("LoadDate", "ClassCode", "FirmId", "Asset", "BankAccId", "SettleDate", "PosType") +); + + +-- Заявки на участие в размещении (из шлюза POService) +CREATE TABLE "POServiceOrder" +( + "OrderNum" decimal(20, 0) NOT NULL, -- Номер заявки + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса модуля размещения + + "PosClassCode" varchar(12) NULL, -- Код класса модуля размещения (из шлюза POService) + "SecCode" varchar(12) NULL, -- Код инструмента модуля размещения + "OrderDateTime" timestamp without time zone NULL, -- Дата и время регистрации + "WithdrawDateTime" timestamp without time zone NULL, -- Дата и время снятия/подтверждения снятия + "State" int NULL, -- Статус + "Operation" int NULL, -- Направление + "FirmId" varchar(12) NULL, -- Код фирмы заявки + "Account" varchar(12) NULL, -- Торговый счет + "ClientCode" varchar(12) NULL, -- Код клиента + "BrokerRef" varchar(34) NULL, -- Комментарий + "SettleCode" varchar(12) NULL, -- Код расчетов + "PriceCurrency" varchar(12) NULL, -- Валюта цены + "Price" decimal(19,8) NULL, -- Цена + "OrigQty" decimal(28, 8) NULL, -- Исходное количество заявки (в лотах) + "Qty" decimal(28, 8) NULL, -- Количество заявки после аллокации (в лотах) + "OrigValue" decimal(28, 8) NULL, -- Исходный объем заявки (скалированный) + "Value" decimal(28, 8) NULL, -- Объем заявки после аллокации (скалированный) + "QtyValue" int NULL, -- Количество/Объем + "AccruedInt" decimal(19,2) NULL, -- НКД + "ExecType" int NULL, -- Способ исполнения + "Expiry" timestamp without time zone NULL, -- Дата истечения + "CouponRate" decimal(4,2) NULL, -- Ставка купона + "OnBehalfOfUID" int NULL, -- UID пользователя, от имени которого выставлена заявка + "CanceledByUID" int NULL, -- UID пользователя, от имени которого снята заявка + "CancelReasonCode" int NULL, -- Код причины снятия + "ExchangeTransDiscardReasonCode" int NULL, -- Код причины неотправки транзакции на размещение в ТС + "ExchangeTransStatus" int NULL, -- Статус транзакции на размещение в ТС + "ExchangeTransRejectText" varchar(256) NULL, -- Текст причины отклонения транзакции на размещение в ТС + "ExchangeClassCode" varchar(12) NULL, -- Код класса биржевого размещения + "ExchangeSecCode" varchar(12) NULL, -- Код инструмента биржевого размещения + "ExchangeOrderNum" decimal (20, 0) NULL, -- Номер биржевой заявки + "ExchangeOrderState" int NULL, -- Статус биржевой заявки + "ExchangeOrderQtySh" decimal(28, 8) NULL, -- Количество биржевой заявки (в штуках) + "ExchangeOrderBalanceSh" decimal(28, 8) NULL, -- Остаток биржевой заявки (в штуках) + "RevisionNum" int NULL, -- Номер ревизии заявки + "Reoffer" int NULL, -- Согласие на любую ставку + "EmployeeName" varchar(64) NULL, -- ФИО сотрудника брокера + "EmployeeRegisterDateTime" timestamp without time zone NULL, -- Дата и время регистрации заявки сотрудником брокера + + PRIMARY KEY ("TradeDate", "OrderNum", "ClassCode") +); + + +-- OMS-заявки РЕПО +CREATE TABLE "OMSREPOOrders" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "OrderNum" decimal(20, 0) NOT NULL, -- Номер заявки + "ClassCode" varchar(12) NOT NULL, -- Код класса + + "OrderDateTime" timestamp without time zone NULL, -- Дата и время регистрации + "WithdrawDateTime" timestamp without time zone NULL, -- Дата и время снятия + "SecCode" varchar(12) NULL, -- Код инструмента + "Operation" int NULL, -- Направление + "Account" varchar(12) NULL, -- Торговый счет + "FirmId" varchar(12) NULL, -- Код фирмы + "ClientCode" varchar(12) NULL, -- Код клиента + "ClientName" varchar(316) NULL, -- Имя клиента + "TradingCode" varchar(12) NULL, -- Торговый код клиента + "Qty" decimal(28, 8) NULL, -- Количество + "Price" decimal(19,8) NULL, -- Цена + "Currency" varchar(12) NULL, -- Валюта цены инструмента + "SettleCurrency" varchar(12) NULL, -- Валюта расчетов по сделке РЕПО + "CrossRate" decimal(19, 8) NULL, -- Кросс-курс + "CrossRateSide" varchar(12) NULL, -- Направление кросс-курса + "RepoTerm" int NULL, -- Период РЕПО + "OpenDate" timestamp without time zone NULL, -- Дата открытия + "OpenDateTimeFact" timestamp without time zone NULL, -- Дата и время открытия факт + "ClosingDate" timestamp without time zone NULL, -- Дата закрытия + "ClosingDateTimeFact" timestamp without time zone NULL, -- Дата и время закрытия факт + "Discount" decimal(19, 8) NULL, -- Дисконт + "RepoRate" decimal(19, 8) NULL, -- Ставка РЕПО + "Counterparty" varchar(12) NULL, -- Код контрагента + "CounterpartyName" varchar(316) NULL, -- Контрагент + "SalesUID" int NULL, -- Идентификатор Сейлза + "SalesName" varchar(256) NULL, -- Имя Сейлза + "TraderUID" int NULL, -- Идентификатор Трейдера + "TraderName" varchar(256) NULL, -- Имя Трейдера + "State" int NULL, -- Статус + "Comment" varchar(256) NULL, -- Комментарий + "ProlongedOrderNum" decimal(20, 0) NULL, -- Номер пролонгируемой заявки + "ProlongationOrderNum" decimal(20, 0) NULL, -- Номер заявки-пролонгации + "CorrectedOrderNum" decimal(20, 0) NULL, -- Номер скорректированной заявки + "CorrectionAllowed" int NULL, -- Корректировка разрешен + "ClosingErrorFlag" int NULL, -- Ошибка закрыти + "ClosingErrorText" varchar(1024) NULL, -- Текст ошибки закрытия + "OrderId" varchar(36) NULL, -- Идентификатор заявки + "CancelReason" varchar(128) NULL, -- Причина отмены + "CancelReasonCode" varchar(32) NULL, -- Код причины отмены + "CurSession" int NULL, -- Флаг текущей сесси + "LastActionDate" timestamp without time zone NULL, -- Дата послед. действия + "RepoBasis" int NULL, -- База расчета РЕПО + "Value" decimal(28,8) NULL, -- Объем + "ValueACI" decimal(28,8) NULL, -- Объем с НКД + "SettlePrice" decimal(19,8) NULL, -- Цена в валюте расчетов + "SettlePriceNoDisc" decimal(19,8) NULL, -- Цена в валюте расчетов без диск. + "SettleValue" decimal(28,8) NULL, -- Объем РЕПО + "RepoValue" decimal(28,8) NULL, -- Сумма РЕПО + "DiscRepoValue" decimal(28,8) NULL, -- Объем дисконта + "AccruedInt" decimal(19,8) NULL, -- НКД заявки + "AccruedIntSettle" decimal(19,8) NULL, -- НКД заявки расч. + "RepoValue2" decimal(28,8) NULL, -- Сумма выкупа + "SettleValue2" decimal(28,8) NULL, -- Объем выкупа + "SettlePrice2" decimal(19,8) NULL, -- Цена выкупа + "AccruedInt2Settle" decimal(19,8) NULL, -- НКД заявки 2 расч. + "UsTradeNum" decimal(20, 0) NULL, -- Номер сделки 2 части + "RepoTermTod" int NULL, -- Срок РЕПО на сегодня + "RepoValue2Tod" decimal(28,8) NULL, -- Сумма выкупа на сегодня + "SettleValue2Tod" decimal(28,8) NULL, -- Объем выкупа на сегодня + "AccruedIntSettleTod" decimal(19,8) NULL, -- НКД заявки расч. сегодя + "RepoValue2Fact" decimal(28,8) NULL, -- Сумма выкупа фактич. + "SettleValue2Fact" decimal(28,8) NULL, -- Объем выкупа фактич. + "SettlePrice2Fact" decimal(19,8) NULL, -- Цена выкупа фактич. + + PRIMARY KEY ("TradeDate", "OrderNum", "ClassCode") +); + + +-- Параметры размещения инструментов Сервиса Размещений (POS) +CREATE TABLE "POServiceInfo" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NOT NULL, -- Код класса + "SecCode" varchar(12) NOT NULL, -- Код инструмента + + "Phase1Start" timestamp without time zone NULL, -- Начало периода сбора заявок + "Phase1End" timestamp without time zone NULL, -- Конец периода сбора заявок + "Phase1ClientCancelAction" smallint NULL, /* Режим снятия заявок клиентом для периода сбора заявок: + 1 = Разрешено безусловно + 2 = Разрешен запрос снятия (требует подтверждение брокера) + 3 = Запрещено */ + "Phase1BrokerCancelAction" smallint NULL, /* Режим снятия заявок брокером для периода сбора заявок: + 1 = Разрешено безусловно + 2 = Запрещено */ + "Phase2Start" timestamp without time zone NULL, -- Начало периода обработки заявок + "Phase2End" timestamp without time zone NULL, -- Конец периода обработки заявок + "Phase2ClientCancelAction" smallint NULL, /* Режим снятия заявок клиентом для периода обработки заявок: + 1 = Разрешено безусловно + 2 = Разрешен запрос снятия (требует подтверждение брокера) + 3 = Запрещено */ + "Phase2BrokerCancelAction" smallint NULL, /* Режим снятия заявок брокером для периода обработки заявок: + 1 = Разрешено безусловно + 2 = Запрещено */ + "Phase3Start" timestamp without time zone NULL, -- Начало периода отправки заявок на биржу + "Phase3End" timestamp without time zone NULL, -- Конец периода отправки заявок на биржу + "Phase3ClientCancelAction" smallint NULL, /* Режим снятия заявок клиентом для периода отправки заявок: + 1 = Разрешено безусловно + 2 = Разрешен запрос снятия (требует подтверждение брокера) + 3 = Запрещено */ + "Phase3BrokerCancelAction" smallint NULL, /* Режим снятия заявок брокером для периода отправки заявок: + 1 = Разрешено безусловно + 2 = Запрещено */ + "OfferPrice" decimal(19,8) NULL, -- Цена размещения + "OfferType" int NULL, -- Тип размещения + "MinCouponRate" decimal(4,2) NULL, -- Минимальное значение ставки купона + "MaxCouponRate" decimal(4,2) NULL, -- Максимальное значение ставки купона + "FinalCouponRate" decimal(4,2) NULL, -- Итоговое значение ставки купона + "OrderPlacementMode" int NULL, -- Режим выставления заявок в ТС + "MinAmountIncrement" decimal(28,8) NULL, -- Шаг объема + "MinOfferingPrice" decimal(19,8) NULL, -- Минимальная цена размещения + "MaxOfferingPrice" decimal(19,8) NULL, -- Максимальная цена размещения + "OperationType" smallint NULL, /* Тип операции по инструменту: + 1 = Размещение + 2 = Выкуп */ + "BuyBackPrice" decimal(19,8) NULL, -- Цена выкупа (итоговая) + "MinBackPrice" decimal(19,8) NULL, -- Минимальная цена выкупа + "MaxBackPrice" decimal(19,8) NULL, -- Максимальная цена выкупа + "CouponRateMode" smallint NULL, /* Режим заполнения ставки купона на заявке размещения: + 1 = Допускается явная и любая ставка + 2 = Допускается только явная ставка + 3 = Допускается только любая ставка */ + "Description" varchar(256) NULL, -- Описание размещения + "NumberOfCoupons" smallint NULL, -- Количество купонов + "AmortizationIndicator" smallint NULL, /* Признак наличия амортизации + 0 = Нет + 1 = Есть */ + "Counterparty" varchar(12) NULL, -- Контрагент. Указывается в транзакции перевыставления POS-заявки в ТС адресной заявкой (при использовании биржевого РПС-режима) + "SettleCode" varchar(12) NULL, -- Код расчетов, указываемые в транзакции перевыставления POS-заявки в ТС при наличии соответствующего поля + "IssuerName" varchar(64) NULL, -- Наименование эмитента + "Series" varchar(32) NULL, -- Серия выпуска + "FaceAmount" decimal(28,8) NULL, -- Номинальный объем выпуска + + PRIMARY KEY ("TradeDate", "ClassCode", "SecCode") +); + + +-- Таблица расчетных кодов ФР/ВР МБ +CREATE TABLE "BankAcc" +( + "ClassCode" varchar(12) NOT NULL, -- Код класса + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "FirmId" varchar(12) NOT NULL, -- Идентификатор фирмы + "BankAccId" varchar(12) NOT NULL, -- Код позиции + + "Description" varchar(30) NULL, -- Описание + "Defaulter" int NULL, -- Дополнительная сессия + "EarlySettle" int NULL, -- Ранние расчеты + "TradingClosed" int NULL, -- Торги закончены + "Status" int NULL, -- Статус + "FullCoveredBuy" int NULL, -- Запрет необеспеченных торгов УК + "ClientCode" varchar(12) NULL, -- Код клиента + "ClearingFirmID" varchar(12) NULL, -- Клиринговая фирма (Идентификатор фирмы - Участника Клиринга) + "ClearingBankAccID" varchar(12) NULL, -- Клиринговый расчетный код (Идентификатор клирингового расчетного счета в НКЦ) + "BankAccType" int NULL, -- Тип + "GCPoolID" varchar(12) NULL, -- Идентификатор пула + "UnifiedPool" int NULL, -- Единый пул + "EarlySettleTime" timestamp without time zone NULL, -- Время ранних расчетов + "TradingClosedTime" timestamp without time zone NULL, -- Время прекращения торгов + "CommBankAccID" varchar(12) NULL, -- Идентификатор расчетного счета/кода в клиринговой организации для списания оборотной комиссии + "CalcPercent" int NULL, -- Учитывать процентный риск + "ClearingMode" int NULL, -- Юридическая клиринговая модель + "CMControlLevel3" int NULL, -- Участник клиринга управляет третьим уровнем + "CMDiscloseClients" int NULL, -- Раскрытие информации о клиентах Участнику клиринга + "ProtectedClient" int NULL, -- ризнак обособленного клиента + "MorningSession" int NULL, -- ризнак доступности в утреннюю сессию + + PRIMARY KEY ("ClassCode", "TradeDate", "FirmId", "BankAccId") +); + + +-- Таблица данных о событиях, связанных с получением оповещений от Сервера Quik +CREATE TABLE "QuikEvents" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "EventTime" timestamp without time zone NOT NULL, -- Момент (дата и время) экспорта события + "EventType" int NOT NULL, -- Тип события + + "EventCode" int NOT NULL -- Код события +); + + +-- Таблица данных о информации о настроенном календаре Сервера Quik +CREATE TABLE "Calendars" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "CalendarDate" timestamp without time zone NOT NULL, -- Дата расчётов + "Priority" smallint NOT NULL, -- Приоритет + "CurrCode" varchar(12) NULL, -- Код вылюты + "ClassCode" varchar(12) NULL, -- Код класса + "SecCode" varchar(12) NULL, -- Код инструмента + "SecType" smallint NULL, -- Тип кода инструмента + + "DateType" smallint NOT NULL -- Тип календарной даты +); + + +-- Журнал денежных позиций (используется при ведении позиций по сроку расчетов) +create table "MoneyLimitsJournal" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "Tag" varchar(4) NOT NULL, -- Группа + "CurrCode" varchar(12) NOT NULL, -- Валюта + "LimitKind" varchar(5) NOT NULL, -- срок расчётов + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "CurrentBal" decimal(28,8) NULL, -- текущая позиция + "CrossRate" decimal(28,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + "LongDiscount" decimal(28,8) NULL, -- эффективный дисконт длинной позиции + "ShortDiscount" decimal(28,8) NULL, -- эффективный дисконт короткой позиции + + PRIMARY KEY ("FirmID", "ClientCode", "Tag", "CurrCode", "LimitKind", "CalcDateTime") +); + + +-- Журнал денежных позиций (используется при ведении позиций по календарным датам) +create table "MoneyLimitsJournalByDates" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "Tag" varchar(4) NOT NULL, -- Группа + "CurrCode" varchar(12) NOT NULL, -- Валюта + "SettleDate" timestamp without time zone NOT NULL, -- срок расчётов + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "CurrentBal" decimal(28,8) NULL, -- текущая позиция + "CrossRate" decimal(28,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + "LongDiscount" decimal(28,8) NULL, -- эффективный дисконт длинной позиции + "ShortDiscount" decimal(28,8) NULL, -- эффективный дисконт короткой позиции + + PRIMARY KEY ("FirmID", "ClientCode", "Tag", "CurrCode", "SettleDate", "CalcDateTime") +); + + +-- Журнал позиций по инструментам (используется при ведении позиций по сроку расчетов) +create table "DepoLimitsJournal" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "Trdacc" varchar(12) NOT NULL, -- Торговый счет + "LimitKind" varchar(5) NOT NULL, -- срок расчётов + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "CurrentBal" decimal(28,8) NULL, -- текущая позиция + "ValuationClassCode" varchar(12) NULL, -- класс оценки + "Price" decimal(28,8) NULL, -- цена, с использованием которой оценивается позиция в стоимости портфеля и марже + "PriceCurrCode" varchar(12) NULL, -- валюта цены + "CrossRate" decimal(19,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + "LongDiscount" decimal(28,8) NULL, -- эффективный дисконт длинной позиции + "ShortDiscount" decimal(28,8) NULL, -- эффективный дисконт короткой позиции + + PRIMARY KEY ("FirmID", "ClientCode", "SecCode", "Trdacc", "LimitKind", "CalcDateTime") +); + + +-- Журнал позиций по инструментам (используется при ведении позиций по календарным датам) +create table "DepoLimitsJournalByDates" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "Trdacc" varchar(12) NOT NULL, -- Торговый счет + "SettleDate" timestamp without time zone NOT NULL, -- срок расчётов + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "CurrentBal" decimal(28,8) NULL, -- текущая позиция + "ValuationClassCode" varchar(12) NULL, -- класс оценки + "Price" decimal(28,8) NULL, -- цена, с использованием которой оценивается позиция в стоимости портфеля и марже + "PriceCurrCode" varchar(12) NULL, -- валюта цены + "CrossRate" decimal(19,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + "LongDiscount" decimal(28,8) NULL, -- эффективный дисконт длинной позиции + "ShortDiscount" decimal(28,8) NULL, -- эффективный дисконт короткой позиции + + PRIMARY KEY ("FirmID", "ClientCode", "SecCode", "Trdacc", "SettleDate", "CalcDateTime") +); + + +-- Журнал ограничений по клиентским счетам +create table "FuturesLimitsJournal" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор участника торгов + "TrdAccID" varchar(12) NOT NULL, -- Торговый счет + "Type" varchar(64) NOT NULL, -- Тип лимита + "CurrCode" varchar(12) NOT NULL, -- Валюта, в которой транслируется ограничение + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "ClientCode" varchar(12) NULL, -- Код клиента на фондовом рынке + "CbpLimit" decimal(19, 4) NULL, -- Текущий лимит открытых позиций + "IndicativeVarMargin" decimal(28,8) NULL, -- Индикативная вариационная маржа + "OptionsPremium" decimal(19, 4) NULL, -- Премии по опционам + "GoWithoutOrders" decimal(28,8) NULL, -- ГО без заявок с учетом текущего реализованного риска + "CbplUsed" decimal(19, 4) NULL, -- Текущие чистые позиции + "CalcMinMarginCoeff" decimal(19, 4) NULL, -- Коэффициент для расчета минимальной маржи + "SettleCurrCode" varchar(12) NULL, -- валюта расчетов + "CrossRate" decimal(28,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + + PRIMARY KEY ("FirmID", "TrdAccID", "Type", "CurrCode", "CalcDateTime") +); + + +-- Журнал позиций по инструментам срочного рынка +create table "FuturesHoldingJournal" +( + "FirmID" varchar(12) NOT NULL, -- Идентификатор фирмы + "SecCode" varchar(12) NOT NULL, -- Код финансового инструмента + "TrdaccID" varchar(12) NOT NULL, -- Торговый счет + "Type" varchar(40) NOT NULL, -- Тип группировки торговых счетов + "CalcDateTime" timestamp without time zone NOT NULL, -- момент расчёта портфеля + + "ClientCode" varchar(12) NULL, -- Код клиента на фондовом рынке + "CurrentBal" decimal(28,8) NULL, -- текущая позиция + "VarMargin" decimal(28,8) NULL, -- текущая вариационная маржа + "ValuationClassCode" varchar(12) NULL, -- класс оценки + "SettlePrice" decimal(28,8) NULL, -- расчетная цена, с использованием которой оценивается позиция в стоимости портфеля и марже + "SettlePriceCurrCode" varchar(12) NULL, -- валюта цены + "CrossRate" decimal(19,8) NULL, -- кросс-курс (курс валюты к валюте портфеля) + "LongDiscount" decimal(28,8) NULL, -- эффективный дисконт длинной позиции + "ShortDiscount" decimal(28,8) NULL, -- эффективный дисконт короткой позиции + "Volatility" decimal(28,8) NULL, -- волатильность (для опционов) + + PRIMARY KEY ("FirmID", "SecCode", "TrdaccID", "Type", "CalcDateTime") +); + + +-- Относительные ставки риска по инструментам фонового рынка +create table "RmRelativeFond" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NULL, -- Код класса инструментов + "Asset" varchar(12) NOT NULL, -- Код финансового инструмента + "RangeNum" int NOT NULL, -- Номер диапазона + "RateCurrCode" varchar(12) NOT NULL, -- Валюта ставки риска + "RelativeTo" varchar(12) NOT NULL, -- Индикатор (актив относительно которого посчитана ставка риска) + + "Rate" decimal(19,3) NULL, -- Относительная ставка риска (риск отклонения при изменении цены актива относительно изменения цены базового индикатора) + "Indicative" int NULL, -- Показатель зависимости между изменениями цен + + PRIMARY KEY ("TradeDate", "ClassCode", "Asset", "RangeNum", "RateCurrCode", "RelativeTo") +); + + +-- Относительные ставки риска по инструментам валютного рынка +create table "RmRelativeCurr" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NULL, -- Код класса инструментов + "Asset" varchar(12) NOT NULL, -- Код финансового инструмента + "RangeNum" int NOT NULL, -- Номер диапазона + "RateCurrCode" varchar(12) NOT NULL, -- Валюта ставки риска + "RelativeTo" varchar(12) NOT NULL, -- Индикатор (актив относительно которого посчитана ставка риска) + + "Rate" decimal(19,3) NULL, -- Относительная ставка риска (риск отклонения при изменении цены актива относительно изменения цены базового индикатора) + "Indicative" int NULL, -- Показатель зависимости между изменениями цен + + PRIMARY KEY ("TradeDate", "ClassCode", "Asset", "RangeNum", "RateCurrCode", "RelativeTo") +); + + +-- Относительные ставки риска по инструментам срочного рынка +create table "RmRelativeFutures" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NULL, -- Код класса инструментов + "Asset" varchar(12) NOT NULL, -- Код финансового инструмента + "RangeNum" int NOT NULL, -- Номер диапазона + "RateCurrCode" varchar(12) NOT NULL, -- Валюта ставки риска + "RelativeTo" varchar(12) NOT NULL, -- Индикатор (актив относительно которого посчитана ставка риска) + + "Rate" decimal(19,3) NULL, -- Относительная ставка риска (риск отклонения при изменении цены актива относительно изменения цены базового индикатора) + "Indicative" int NULL, -- Показатель зависимости между изменениями цен + + PRIMARY KEY ("TradeDate", "ClassCode", "Asset", "RangeNum", "RateCurrCode", "RelativeTo") +); + + +-- Параметры процентных рисков по инструментам срочного рынка +create table "RmPriceRangeFutures" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "ClassCode" varchar(12) NULL, -- Код класса инструментов + "Asset" varchar(12) NOT NULL, -- Код финансового инструмента + "RangeNum" int NOT NULL, -- Номер диапазона + "RateCurrCode" varchar(12) NOT NULL, -- Валюта ставки риска + + "PriceDropRate" decimal(19,3) NULL, -- Ставка риска падения цены, % + "PriceIncreaseRate" decimal(19,3) NULL, -- Ставка риска роста цены, % + + PRIMARY KEY ("TradeDate", "ClassCode", "Asset", "RangeNum", "RateCurrCode") +); + + +-- Профили клиентов +create table "ClientProfiles" +( + "TradeDate" timestamp without time zone NOT NULL, -- Дата торговой сессии + "FirmId" varchar(12) NOT NULL, -- Код фирмы + "ClientCode" varchar(12) NOT NULL, -- Код клиента + + "CommissionTemplateId" int NULL, -- Идентификатор шаблона комиссии + "CommissionTemplateDesc" text NULL, -- Описаиние шаблона комиссии + "MarginalTemplateId" int NULL, -- Идентификатор маржинального шаблона + "MarginalTemplateDesc" text NULL, -- Описание маржинального шаблона + "RestrictionTemplateId" int NULL, -- Идентификатор шаблона ограничений + "RestrictionTemplateDesc" text NULL, -- Описание шаблона ограничений + "ComplexMask" decimal(19,0) NULL, -- Маска сложного финансового инструмента + + PRIMARY KEY ("TradeDate", "FirmId", "ClientCode") +); \ No newline at end of file diff --git a/internal/lkgateway/web/quikexport/info.ini.tmpl b/internal/lkgateway/web/quikexport/info.ini.tmpl new file mode 100644 index 0000000..52a2968 --- /dev/null +++ b/internal/lkgateway/web/quikexport/info.ini.tmpl @@ -0,0 +1,13 @@ +[connect] +trade-server-ip-address=127.0.0.1 +auth-name=xxx +check-tmo=150 +port=15100 +user_name=xxx +user_password=xxx +AutoHeartBeat=1 + +[proxy] +use-proxy=0 +proxy-server-ip-address= +proxy-port= diff --git a/internal/lkgateway/web/quikexport/npr_migration.sql b/internal/lkgateway/web/quikexport/npr_migration.sql new file mode 100644 index 0000000..7a04e36 --- /dev/null +++ b/internal/lkgateway/web/quikexport/npr_migration.sql @@ -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; diff --git a/internal/lkgateway/web/static/npr/chart.umd.min.js b/internal/lkgateway/web/static/npr/chart.umd.min.js new file mode 100644 index 0000000..9a07c2f --- /dev/null +++ b/internal/lkgateway/web/static/npr/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.0/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.0 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).reduce(((e,i)=>e||S(t[i])),!1);const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.0";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e;const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/internal/lkgateway/web/static/npr/htmx.min.js b/internal/lkgateway/web/static/npr/htmx.min.js new file mode 100644 index 0000000..de5f0f1 --- /dev/null +++ b/internal/lkgateway/web/static/npr/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else if(typeof module==="object"&&module.exports){module.exports=t()}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var Q={onLoad:F,process:zt,on:de,off:ge,trigger:ce,ajax:Nr,find:C,findAll:f,closest:v,values:function(e,t){var r=dr(e,t||"post");return r.values},remove:_,addClass:z,removeClass:n,toggleClass:$,takeClass:W,defineExtension:Ur,removeExtension:Br,logAll:V,logNone:j,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get"],selfRequestsOnly:false,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null},parseInterval:d,_:t,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){var t=new WebSocket(e,[]);t.binaryType=Q.config.wsBinaryType;return t},version:"1.9.12"};var r={addTriggerHandler:Lt,bodyContains:se,canAccessLocalStorage:U,findThisElement:xe,filterValues:yr,hasAttribute:o,getAttributeValue:te,getClosestAttributeValue:ne,getClosestMatch:c,getExpressionVars:Hr,getHeaders:xr,getInputValues:dr,getInternalData:ae,getSwapSpecification:wr,getTriggerSpecs:it,getTarget:ye,makeFragment:l,mergeObjects:le,makeSettleInfo:T,oobSwap:Ee,querySelectorExt:ue,selectAndSwap:je,settleImmediately:nr,shouldCancel:ut,triggerEvent:ce,triggerErrorEvent:fe,withExtensions:R};var w=["get","post","put","delete","patch"];var i=w.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");var S=e("head"),q=e("title"),H=e("svg",true);function e(e,t){return new RegExp("<"+e+"(\\s[^>]*>|>)([\\s\\S]*?)<\\/"+e+">",!!t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){return e.parentElement}function re(){return document}function c(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function L(e,t,r){var n=te(t,r);var i=te(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function ne(t,r){var n=null;c(t,function(e){return n=L(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function A(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function s(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=re().createDocumentFragment()}return i}function N(e){return/",0);var a=i.querySelector("template").content;if(Q.config.allowScriptTags){oe(a.querySelectorAll("script"),function(e){if(Q.config.inlineScriptNonce){e.nonce=Q.config.inlineScriptNonce}e.htmxExecuted=navigator.userAgent.indexOf("Firefox")===-1})}else{oe(a.querySelectorAll("script"),function(e){_(e)})}return a}switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return s(""+n+"
    ",1);case"col":return s(""+n+"
    ",2);case"tr":return s(""+n+"
    ",2);case"td":case"th":return s(""+n+"
    ",3);case"script":case"style":return s("
    "+n+"
    ",1);default:return s(n,0)}}function ie(e){if(e){e()}}function I(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return I(e,"Function")}function P(e){return I(e,"Object")}function ae(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function M(e){var t=[];if(e){for(var r=0;r=0}function se(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return re().body.contains(e.getRootNode().host)}else{return re().body.contains(e)}}function D(e){return e.trim().split(/\s+/)}function le(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function E(e){try{return JSON.parse(e)}catch(e){b(e);return null}}function U(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function B(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function t(e){return Tr(re().body,function(){return eval(e)})}function F(t){var e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function j(){Q.logger=null}function C(e,t){if(t){return e.querySelector(t)}else{return C(re(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(re(),e)}}function _(e,t){e=p(e);if(t){setTimeout(function(){_(e);e=null},t)}else{e.parentElement.removeChild(e)}}function z(e,t,r){e=p(e);if(r){setTimeout(function(){z(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=p(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function $(e,t){e=p(e);e.classList.toggle(t)}function W(e,t){e=p(e);oe(e.parentElement.children,function(e){n(e,t)});z(e,t)}function v(e,t){e=p(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function G(e,t){return e.substring(e.length-t.length)===t}function J(e){var t=e.trim();if(g(t,"<")&&G(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function Z(e,t){if(t.indexOf("closest ")===0){return[v(e,J(t.substr(8)))]}else if(t.indexOf("find ")===0){return[C(e,J(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[K(e,J(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[Y(e,J(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return re().querySelectorAll(J(t))}}var K=function(e,t){var r=re().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ue(e,t){if(t){return Z(e,t)[0]}else{return Z(re().body,e)[0]}}function p(e){if(I(e,"String")){return C(e)}else{return e}}function ve(e,t,r){if(k(t)){return{target:re().body,event:e,listener:t}}else{return{target:p(e),event:t,listener:r}}}function de(t,r,n){jr(function(){var e=ve(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=k(r);return e?r:n}function ge(t,r,n){jr(function(){var e=ve(t,r,n);e.target.removeEventListener(e.event,e.listener)});return k(r)?r:n}var pe=re().createElement("output");function me(e,t){var r=ne(e,t);if(r){if(r==="this"){return[xe(e,t)]}else{var n=Z(e,r);if(n.length===0){b('The selector "'+r+'" on '+t+" returned no matches!");return[pe]}else{return n}}}}function xe(e,t){return c(e,function(e){return te(e,t)!=null})}function ye(e){var t=ne(e,"hx-target");if(t){if(t==="this"){return xe(e,"hx-target")}else{return ue(e,t)}}else{var r=ae(e);if(r.boosted){return re().body}else{return e}}}function be(e){var t=Q.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=re().querySelectorAll(t);if(r){oe(r,function(e){var t;var r=i.cloneNode(true);t=re().createDocumentFragment();t.appendChild(r);if(!Se(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!ce(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Fe(o,e,e,t,a)}oe(a.elts,function(e){ce(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);fe(re().body,"htmx:oobErrorNoTarget",{content:i})}return e}function Ce(e,t,r){var n=ne(e,"hx-select-oob");if(n){var i=n.split(",");for(var a=0;a0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();we(e,i);s.tasks.push(function(){we(e,a)})}}})}function Oe(e){return function(){n(e,Q.config.addedClass);zt(e);Nt(e);qe(e);ce(e,"htmx:load")}}function qe(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Te(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;z(i,Q.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Oe(i))}}}function He(e,t){var r=0;while(r-1){var t=e.replace(H,"");var r=t.match(q);if(r){return r[2]}}}function je(e,t,r,n,i,a){i.title=Ve(n);var o=l(n);if(o){Ce(r,o,i);o=Be(r,o,a);Re(o);return Fe(e,r,t,o,i)}}function _e(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=E(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!P(o)){o={value:o}}ce(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Tr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){fe(re().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Qe(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function y(e,t){var r="";while(e.length>0&&!t.test(e[0])){r+=e.shift()}return r}function tt(e){var t;if(e.length>0&&Ze.test(e[0])){e.shift();t=y(e,Ke).trim();e.shift()}else{t=y(e,x)}return t}var rt="input, textarea, select";function nt(e,t,r){var n=[];var i=Ye(t);do{y(i,Je);var a=i.length;var o=y(i,/[,\[\s]/);if(o!==""){if(o==="every"){var s={trigger:"every"};y(i,Je);s.pollInterval=d(y(i,/[,\[\s]/));y(i,Je);var l=et(e,i,"event");if(l){s.eventFilter=l}n.push(s)}else if(o.indexOf("sse:")===0){n.push({trigger:"sse",sseEvent:o.substr(4)})}else{var u={trigger:o};var l=et(e,i,"event");if(l){u.eventFilter=l}while(i.length>0&&i[0]!==","){y(i,Je);var f=i.shift();if(f==="changed"){u.changed=true}else if(f==="once"){u.once=true}else if(f==="consume"){u.consume=true}else if(f==="delay"&&i[0]===":"){i.shift();u.delay=d(y(i,x))}else if(f==="from"&&i[0]===":"){i.shift();if(Ze.test(i[0])){var c=tt(i)}else{var c=y(i,x);if(c==="closest"||c==="find"||c==="next"||c==="previous"){i.shift();var h=tt(i);if(h.length>0){c+=" "+h}}}u.from=c}else if(f==="target"&&i[0]===":"){i.shift();u.target=tt(i)}else if(f==="throttle"&&i[0]===":"){i.shift();u.throttle=d(y(i,x))}else if(f==="queue"&&i[0]===":"){i.shift();u.queue=y(i,x)}else if(f==="root"&&i[0]===":"){i.shift();u[f]=tt(i)}else if(f==="threshold"&&i[0]===":"){i.shift();u[f]=y(i,x)}else{fe(e,"htmx:syntax:error",{token:i.shift()})}}n.push(u)}}if(i.length===a){fe(e,"htmx:syntax:error",{token:i.shift()})}y(i,Je)}while(i[0]===","&&i.shift());if(r){r[t]=n}return n}function it(e){var t=te(e,"hx-trigger");var r=[];if(t){var n=Q.config.triggerSpecsCache;r=n&&n[t]||nt(e,t,n)}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,rt)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function at(e){ae(e).cancelled=true}function ot(e,t,r){var n=ae(e);n.timeout=setTimeout(function(){if(se(e)&&n.cancelled!==true){if(!ct(r,e,Wt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}ot(e,t,r)}},r.pollInterval)}function st(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function lt(t,r,e){if(t.tagName==="A"&&st(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=ee(t,"href")}else{var a=ee(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=ee(t,"action")}e.forEach(function(e){ht(t,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(n,i,e,t)},r,e,true)})}}function ut(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&v(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function ft(e,t){return ae(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ct(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){fe(re().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function ht(a,o,e,s,l){var u=ae(a);var t;if(s.from){t=Z(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ae(e);t.lastValue=e.value})}oe(t,function(n){var i=function(e){if(!se(a)){n.removeEventListener(s.trigger,i);return}if(ft(a,e)){return}if(l||ut(e,a)){e.preventDefault()}if(ct(s,a,e)){return}var t=ae(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ae(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle>0){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay>0){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{ce(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var vt=false;var dt=null;function gt(){if(!dt){dt=function(){vt=true};window.addEventListener("scroll",dt);setInterval(function(){if(vt){vt=false;oe(re().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){pt(e)})}},200)}}function pt(t){if(!o(t,"data-hx-revealed")&&X(t)){t.setAttribute("data-hx-revealed","true");var e=ae(t);if(e.initHash){ce(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){ce(t,"revealed")},{once:true})}}}function mt(e,t,r){var n=D(r);for(var i=0;i=0){var t=wt(n);setTimeout(function(){xt(s,r,n+1)},t)}};t.onopen=function(e){n=0};ae(s).webSocket=t;t.addEventListener("message",function(e){if(yt(s)){return}var t=e.data;R(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=M(n.children);for(var a=0;a0){ce(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(ut(e,u)){e.preventDefault()}})}else{fe(u,"htmx:noWebSocketSourceError")}}function wt(e){var t=Q.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}b('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function St(e,t,r){var n=D(r);for(var i=0;i0){setTimeout(i,n)}else{i()}}function Ht(t,i,e){var a=false;oe(w,function(r){if(o(t,"hx-"+r)){var n=te(t,"hx-"+r);a=true;i.path=n;i.verb=r;e.forEach(function(e){Lt(t,e,i,function(e,t){if(v(e,Q.config.disableSelector)){m(e);return}he(r,n,e,t)})})}});return a}function Lt(n,e,t,r){if(e.sseEvent){Rt(n,r,e.sseEvent)}else if(e.trigger==="revealed"){gt();ht(n,r,t,e);pt(n)}else if(e.trigger==="intersect"){var i={};if(e.root){i.root=ue(n,e.root)}if(e.threshold){i.threshold=parseFloat(e.threshold)}var a=new IntersectionObserver(function(e){for(var t=0;t0){t.polling=true;ot(n,r,e)}else{ht(n,r,t,e)}}function At(e){if(!e.htmxExecuted&&Q.config.allowScriptTags&&(e.type==="text/javascript"||e.type==="module"||e.type==="")){var t=re().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}var r=e.parentElement;try{r.insertBefore(t,e)}catch(e){b(e)}finally{if(e.parentElement){e.parentElement.removeChild(e)}}}}function Nt(e){if(h(e,"script")){At(e)}oe(f(e,"script"),function(e){At(e)})}function It(e){var t=e.attributes;if(!t){return false}for(var r=0;r0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Bt(o)}for(var l in r){Ft(e,l,r[l])}}}function jt(e){Ae(e);for(var t=0;tQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(re().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Yt(e){if(!U()){return null}e=B(e);var t=E(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){ce(re().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Zt();var r=T(t);var n=Ve(this.response);if(n){var i=C("title");if(i){i.innerHTML=n}else{window.document.title=n}}Ue(t,e,r);nr(r.tasks);Jt=a;ce(re().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{fe(re().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function ar(e){er();e=e||location.pathname+location.search;var t=Yt(e);if(t){var r=l(t.content);var n=Zt();var i=T(n);Ue(n,r,i);nr(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Jt=e;ce(re().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{ir(e)}}}function or(e){var t=me(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Q.config.requestClass)});return t}function sr(e){var t=me(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function lr(e,t){oe(e,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Q.config.requestClass)}});oe(t,function(e){var t=ae(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function ur(e,t){for(var r=0;r=0}function wr(e,t){var r=t?t:ne(e,"hx-swap");var n={swapStyle:ae(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ae(e).boosted&&!br(e)){n["show"]="top"}if(r){var i=D(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var v=o.substr("focus-scroll:".length);n["focusScroll"]=v=="true"}else if(a==0){n["swapStyle"]=o}else{b("Unknown modifier in hx-swap: "+o)}}}}return n}function Sr(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function Er(t,r,n){var i=null;R(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Sr(r)){return mr(n)}else{return pr(n)}}}function T(e){return{tasks:[],elts:[e]}}function Cr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ue(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ue(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Rr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=te(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Tr(e,function(){return Function("return ("+a+")")()},{})}else{s=E(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Rr(u(e),t,r,n)}function Tr(e,t,r){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return r}}function Or(e,t){return Rr(e,"hx-vars",true,t)}function qr(e,t){return Rr(e,"hx-vals",false,t)}function Hr(e){return le(Or(e),qr(e))}function Lr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Ar(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(re().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return t.test(e.getAllResponseHeaders())}function Nr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||I(r,"String")){return he(e,t,null,null,{targetOverride:p(r),returnPromise:true})}else{return he(e,t,p(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:p(r.target),swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return he(e,t,null,null,{returnPromise:true})}}function Ir(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function kr(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!n){return false}}return ce(e,"htmx:validateUrl",le({url:i,sameHost:n},r))}function he(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=re().body}var M=a.handler||Mr;var X=a.select||null;if(!se(n)){ie(o);return l}var u=a.targetOverride||ye(n);if(u==null||u==pe){fe(n,"htmx:targetError",{target:te(n,"hx-target")});ie(s);return l}var f=ae(n);var c=f.lastButtonClicked;if(c){var h=ee(c,"formaction");if(h!=null){r=h}var v=ee(c,"formmethod");if(v!=null){if(v.toLowerCase()!=="dialog"){t=v}}}var d=ne(n,"hx-confirm");if(e===undefined){var D=function(e){return he(t,r,n,i,a,!!e)};var U={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:d};if(ce(n,"htmx:confirm",U)===false){ie(o);return l}}var g=n;var p=ne(n,"hx-sync");var m=null;var x=false;if(p){var B=p.split(":");var F=B[0].trim();if(F==="this"){g=xe(n,"hx-sync")}else{g=ue(n,F)}p=(B[1]||"drop").trim();f=ae(g);if(p==="drop"&&f.xhr&&f.abortable!==true){ie(o);return l}else if(p==="abort"){if(f.xhr){ie(o);return l}else{x=true}}else if(p==="replace"){ce(g,"htmx:abort")}else if(p.indexOf("queue")===0){var V=p.split(" ");m=(V[1]||"last").trim()}}if(f.xhr){if(f.abortable){ce(g,"htmx:abort")}else{if(m==null){if(i){var y=ae(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){m=y.triggerSpec.queue}}if(m==null){m="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(m==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="all"){f.queuedRequests.push(function(){he(t,r,n,i,a)})}else if(m==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){he(t,r,n,i,a)})}ie(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var j=ne(n,"hx-prompt");if(j){var S=prompt(j);if(S===null||!ce(n,"htmx:prompt",{prompt:S,target:u})){ie(o);w();return l}}if(d&&!e){if(!confirm(d)){ie(o);w();return l}}var E=xr(n,u,S);if(t!=="get"&&!Sr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(a.headers){E=le(E,a.headers)}var _=dr(n,t);var C=_.errors;var R=_.values;if(a.values){R=le(R,a.values)}var z=Hr(n);var $=le(R,z);var T=yr($,n);if(Q.config.getCacheBusterParam&&t==="get"){T["org.htmx.cache-buster"]=ee(u,"id")||"true"}if(r==null||r===""){r=re().location.href}var O=Rr(n,"hx-request");var W=ae(n).boosted;var q=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:T,unfilteredParameters:$,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||O.credentials||Q.config.withCredentials,timeout:a.timeout||O.timeout||Q.config.timeout,path:r,triggeringEvent:i};if(!ce(n,"htmx:configRequest",H)){ie(o);w();return l}r=H.path;t=H.verb;E=H.headers;T=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){ce(n,"htmx:validation:halted",H);ie(o);w();return l}var G=r.split("#");var J=G[0];var L=G[1];var A=r;if(q){A=J;var Z=Object.keys(T).length!==0;if(Z){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=pr(T);if(L){A+="#"+L}}}if(!kr(n,A,H)){fe(n,"htmx:invalidPath",H);ie(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(O.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var K=E[N];Lr(b,N,K)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,select:X,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Ir(n);I.pathInfo.responsePath=Ar(b);M(n,I);lr(k,P);ce(n,"htmx:afterRequest",I);ce(n,"htmx:afterOnLoad",I);if(!se(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(se(r)){t=r}}if(t){ce(t,"htmx:afterRequest",I);ce(t,"htmx:afterOnLoad",I)}}ie(o);w()}catch(e){fe(n,"htmx:onLoadError",le({error:e},I));throw e}};b.onerror=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendError",I);ie(s);w()};b.onabort=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:sendAbort",I);ie(s);w()};b.ontimeout=function(){lr(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:timeout",I);ie(s);w()};if(!ce(n,"htmx:beforeRequest",I)){ie(o);w();return l}var k=or(n);var P=sr(n);oe(["loadstart","loadend","progress","abort"],function(t){oe([b,b.upload],function(e){e.addEventListener(t,function(e){ce(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ce(n,"htmx:beforeSend",I);var Y=q?null:Er(b,n,T);b.send(Y);return l}function Pr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=ne(e,"hx-push-url");var l=ne(e,"hx-replace-url");var u=ae(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function Mr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;var h=u.select;if(!ce(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){_e(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){er();var r=f.getResponseHeader("HX-Location");var v;if(r.indexOf("{")===0){v=E(r);r=v["path"];delete v["path"]}Nr("GET",r,v).then(function(){tr(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){if(f.getResponseHeader("HX-Retarget")==="this"){u.target=l}else{u.target=ue(l,f.getResponseHeader("HX-Retarget"))}}var d=Pr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var g=f.response;var a=f.status>=400;var p=Q.config.ignoreTitle;var o=le({shouldSwap:i,serverResponse:g,isError:a,ignoreTitle:p},u);if(!ce(c,"htmx:beforeSwap",o))return;c=o.target;g=o.serverResponse;a=o.isError;p=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){at(l)}R(l,function(e){g=e.transformResponse(g,f,l)});if(d.type){er()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var v=wr(l,s);if(v.hasOwnProperty("ignoreTitle")){p=v.ignoreTitle}c.classList.add(Q.config.swappingClass);var m=null;var x=null;var y=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(h){r=h}if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}if(d.type){ce(re().body,"htmx:beforeHistoryUpdate",le({history:d},u));if(d.type==="push"){tr(d.path);ce(re().body,"htmx:pushedIntoHistory",{path:d.path})}else{rr(d.path);ce(re().body,"htmx:replacedInHistory",{path:d.path})}}var n=T(c);je(v.swapStyle,c,l,g,n,r);if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){var i=document.getElementById(ee(t.elt,"id"));var a={preventScroll:v.focusScroll!==undefined?!v.focusScroll:!Q.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Q.config.swappingClass);oe(n.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}ce(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!se(l)){o=re().body}_e(f,"HX-Trigger-After-Swap",o)}var s=function(){oe(n.tasks,function(e){e.call()});oe(n.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}ce(e,"htmx:afterSettle",u)});if(u.pathInfo.anchor){var e=re().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!p){var t=C("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Cr(n.elts,v);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!se(l)){r=re().body}_e(f,"HX-Trigger-After-Settle",r)}ie(m)};if(v.settleDelay>0){setTimeout(s,v.settleDelay)}else{s()}}catch(e){fe(l,"htmx:swapError",u);ie(x);throw e}};var b=Q.config.globalViewTransitions;if(v.hasOwnProperty("transition")){b=v.transition}if(b&&ce(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var w=new Promise(function(e,t){m=e;x=t});var S=y;y=function(){document.startViewTransition(function(){S();return w})}}if(v.swapDelay>0){setTimeout(y,v.swapDelay)}else{y()}}if(a){fe(l,"htmx:responseError",le({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Xr={};function Dr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ur(e,t){if(t.init){t.init(r)}Xr[e]=le(Dr(),t)}function Br(e){delete Xr[e]}function Fr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=te(e,"hx-ext");if(t){oe(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Xr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Fr(u(e),r,n)}var Vr=false;re().addEventListener("DOMContentLoaded",function(){Vr=true});function jr(e){if(Vr||re().readyState==="complete"){e()}else{re().addEventListener("DOMContentLoaded",e)}}function _r(){if(Q.config.includeIndicatorStyles!==false){re().head.insertAdjacentHTML("beforeend","")}}function zr(){var e=re().querySelector('meta[name="htmx-config"]');if(e){return E(e.content)}else{return null}}function $r(){var e=zr();if(e){Q.config=le(Q.config,e)}}jr(function(){$r();_r();var e=re().body;zt(e);var t=re().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ae(t);if(r&&r.xhr){r.xhr.abort()}});const r=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){ar();oe(t,function(e){ce(e,"htmx:restored",{document:re(),triggerEvent:ce})})}else{if(r){r(e)}}};setTimeout(function(){ce(e,"htmx:load",{});e=null},0)});return Q}()}); \ No newline at end of file diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index 7055690..967366f 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -1,4 +1,34 @@ {{define "content"}} + +
    +
    +

    ← Назад

    +{{if .Flash}}
    {{.Flash}}
    {{end}}

    Заявка {{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}

    @@ -13,8 +43,38 @@ {{end}}
    ИИС{{.Claim.IIAAgreement.AgreementType}} № {{.Claim.IIAAgreement.AgreementNumber}} от {{.Claim.IIAAgreement.AgreementDate}}, брокер ИНН {{.Claim.IIAAgreement.BrokerINN}}
    + +

    ZIP: отчёт-доказательство (хронология пути) + дословные XML запроса и ответа НРД (windows-1251).

    + {{if .Claim.Investor.ID}} +
    + +
    +
    + + Пусто = без ограничения. ZIP: сводка CSV + комплект по каждой заявке. +
    + {{end}}
    +{{with .Claim.TestResult}} +
    +

    + {{if eq .Status "ok"}}✓ Тест пройден + {{else if eq .Status "mismatch"}}Результат не совпал + {{else if eq .Status "error"}}Ошибка сервиса НРД + {{else}}⏳ Ожидание ответа{{end}} + · сценарий {{.Scenario}} «{{.Title}}» +

    + + + + {{if .Note}}{{end}} +
    Ожидали{{.Expected}}
    Получили{{.Got}}
    Вывод{{.Note}}
    +
    +{{end}} +

    Ценные бумаги ({{len .Claim.Securities}})

    @@ -32,6 +92,129 @@
    + +{{/* ===== QUIK МНП: поручение «Вывод ЦБ (M2M)» ===== */}} +{{$back := printf "/admin/claims/%s" .Claim.ID}} +
    + +
    +

    QUIK МНП — блокировка и списание у клиента

    + все поручения → +
    +{{if .DepoL}}
    🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение №{{.DepoL.DocNo}} создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: {{.DepoL.DocState}}{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}
    ошибка: {{.}}
    {{end}}
    {{end}} + +

    При отправке в НРД bj регистрирует в Модуле неторговых поручений «Вывод ЦБ (M2M)» — QUIK блокирует бумаги на позиции клиента. Отказ контрагента или НРД → поручение снимается с причиной (клиент видит её в терминале). Приём НРД нашего поручения 16 → подпись депозитария, бумаги списываются.

    + {{if not .Quik}}

    Поручений МНП по этой заявке нет{{if .Claim.M2MGUID}} — можно зарегистрировать вручную: +

    + + + + или привязать поручение, поданное клиентом из терминала: + +
    + {{end}}

    {{end}} + + {{range .Quik}} +
    +
    + {{.ISIN}} × {{.Qty}} + клиент QUIK {{.ClientID}}{{if .Contract}} · {{.Contract}}{{end}}{{if .InstrNo}} · поручение МНП №{{.InstrNo}}{{end}} + {{.StateRu}} +
    + {{if .Err}}
    {{.Err}}
    {{end}} + {{if .Steps}}
    {{range .Steps}}{{msk .At}} · {{.Action}}{{if .ActionID}} #{{.ActionID}}{{end}}: {{.Reply}}{{end}}
    {{end}} +
    + {{if eq .State "registered"}} +
    +
    + {{else if or (eq .State "error") (eq .State "manual") (not .InstrNo)}} +
    +
    + {{end}} +
    +
    + {{end}} +
    +{{if .Depo}} +
    + +
    +

    Депозитарное поручение 16 (списание)

    + {{if .Depo.Done}}проведено · MS010 + {{else if .Depo.Submitted}}подано, ждём MS010 + {{else}}требует подачи{{end}} +
    +{{if .Watch}}
    ⏱ Срок по регламенту: {{.Watch.StatusRu}}
    {{end}} + +

    Мы — списывающий депозитарий. НРД прислал черновик формы 16; подайте его — НРД сматчит со встречным 16/1 контрагента и пришлёт отчёт MS010 о проведённом списании (бумага ушла контрагенту).

    + +
    +
    + ✓ +
    Черновик 16 получен
    {{msk .Depo.DraftAt}}
    + ⬇ Черновик от НРД
    +
    +
    + {{if .Depo.Submitted}}✓{{else}}2{{end}} +
    Поручение 16 подано
    {{if .Depo.Submitted}}{{msk .Depo.OrderAt}}{{else}}нужно подать в НРД{{end}}
    + {{if .Depo.OrderXML}}⬇ Отправленная форма 16{{end}}
    +
    +
    + {{if .Depo.Done}}✓{{else}}3{{end}} +
    Списание проведено · MS010
    {{if .Depo.Done}}отчёт получен от НРД{{else if .Depo.Submitted}}ждём встречный 16/1 + MS010{{else}}после подачи{{end}}
    + {{if .Depo.Responses}}⬇ Ответ НРД (MS010){{end}}
    +
    +
    + +
    + {{if not .Depo.Submitted}} +
    + +
    + {{end}} + ⬇ Выгрузка 16 для регулятора (весь пакет) +
    + + {{if .Depo.Submitted}} +
    Подано {{msk .Depo.OrderAt}} · пакет ИШ {{.Depo.Pkgs}}
    + {{if .Depo.Responses}} + + + {{range .Depo.Responses}}{{end}} +
    ВремяОтвет НРД
    {{.Time}}{{.Kind}}
    + {{else}} +

    Ответ НРД (квитанция → принятие → отчёт MS010 о списании) появится здесь. Обновите страницу.

    + {{end}} + {{end}} +
    +{{end}} +

    История FSM

    @@ -49,6 +232,25 @@
    +{{if .Claim.RequestRawXML}} +
    +

    Отправленный пакет (M2MTransferRequest)

    +

    Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета #M2MTR, канал TEST3.

    + ⤓ Скачать XML запроса + +
    {{.Claim.RequestRawXML}}
    + +
    +{{end}} + {{if .Claim.M2MResponse}}

    Ответ сервиса МОСТ (M2MTransferResponse)

    @@ -64,7 +266,7 @@ ReferenceIDКодТекст ответа НРД {{range .Claim.M2MResponse.Responses}} - {{.ReferenceID}}{{.Code}}{{.Text}} + {{.ReferenceID}}{{.Code}}{{if .Text}}{{.Text}}{{else}}{{m2mCode .Code}}{{end}} {{end}} @@ -83,6 +285,7 @@

    Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.

    + ⤓ Скачать XML ответа
    {{.Claim.M2MResponse.RawXML}}
    @@ -110,7 +313,7 @@ {{.ReferenceID}} {{ruOutcome .Outcome}} - {{range .RejectCodes}}{{.}} {{end}} + {{range .RejectCodes}}{{m2mCode .}}
    {{end}} {{end}} @@ -130,4 +333,25 @@
    {{end}} +
    {{/* /claim-main */}} + +{{if .Claim.Timeline}} + +{{end}} +
    {{/* /claim-grid */}} {{end}} diff --git a/internal/lkgateway/web/templates/admin_claims.html b/internal/lkgateway/web/templates/admin_claims.html index c44fb90..e4655f7 100644 --- a/internal/lkgateway/web/templates/admin_claims.html +++ b/internal/lkgateway/web/templates/admin_claims.html @@ -8,12 +8,12 @@ {{range .Items}} {{.CreatedAt.Format "02.01 15:04:05"}} - {{slice .ID 0 8}} - {{slice (printf "%s" .M2MGUID) 0 8}} - {{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}. + {{short 8 .ID}} + {{short 8 (printf "%s" .M2MGUID)}} + {{.Investor.LastName}} {{initial .Investor.FirstName}} {{len .Securities}} - {{.TransferringDepositoryINN}} - {{.ReceivingDepositoryINN}} + {{$f := m2mParty .TransferringDepositoryINN}}{{if $f}}{{$f}}
    {{end}}{{.TransferringDepositoryINN}} + {{$t := m2mParty .ReceivingDepositoryINN}}{{if $t}}{{$t}}
    {{end}}{{.ReceivingDepositoryINN}} {{ruState .Status}} детали → diff --git a/internal/lkgateway/web/templates/admin_depo_sim.html b/internal/lkgateway/web/templates/admin_depo_sim.html new file mode 100644 index 0000000..87ac33d --- /dev/null +++ b/internal/lkgateway/web/templates/admin_depo_sim.html @@ -0,0 +1,139 @@ +{{define "content"}} + + +

    ← Назад

    + +
    +

    Имитация сверки с DEPO

    +

    Выберите реального клиента и бумагу из DEPO, при желании измените любое поле и нажмите «Проверить» — отработает та же сверка, что и по настоящим переводам, и покажет ответ: принять или отказ с кодом. В НРД и контрагенту ничего не отправляется.

    + +
    +
    + + +
    + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    Быстрые сценарии — подставят данные, затем нажмите «Проверить»:
    +
    + + + + + + +
    + +
    + + ничего не отправляется +
    +
    +
    + +{{if .Posted}} +
    +
    +

    Ответ сверки (имитация) — {{if eq .Result.Mode "out"}}списание{{else}}зачисление{{end}}

    + {{if .Result.Ran}}{{.Result.Summary}}{{end}} +
    + {{if not .Result.Ran}} +

    Сверка не выполнена: {{.Result.Err}}

    + {{else}} + + + +
    Инвестор в картотеке{{if .Result.Investor}}{{.Result.Investor}}{{else}}не найден{{end}}
    Торговый счёт депо{{if .Result.Account}}{{.Result.Account}} — открыт{{else}}открытого ВТНКЦ-счёта нет{{end}}
    + {{range .Result.Secs}} +

    {{.Security}} → {{if eq .Recommend "confirm"}}принять{{else}}отказ {{.RejectCode}} ({{m2mCode .RejectCode}}){{end}}

    + + + + {{range .Checks}} + + {{end}} + +
    ПроверкаРезультат
    {{if not .OK}}🔴{{else if .Warn}}🟡{{else}}🟢{{end}}{{.Name}}{{if not .OK}} → {{.Code}}{{end}}{{.Detail}}
    + {{end}} +

    🟢 совпало · 🟡 пройдено с предупреждением (данных для отказа в базе нет — решает оператор) · 🔴 не совпало. Так же выглядит сверка в карточке настоящего входящего и в превью исходящей отправки.

    + {{end}} +
    +{{end}} + + +{{end}} diff --git a/internal/lkgateway/web/templates/admin_fancy_queue.html b/internal/lkgateway/web/templates/admin_fancy_queue.html new file mode 100644 index 0000000..481d489 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_fancy_queue.html @@ -0,0 +1,76 @@ +{{define "content"}} +
    +

    Обмен с DEPO (Fancy)

    + Переводы M2M, которые bj передал в депозитарную систему через трансферные таблицы базы {{.DB}} (FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS). Job bj2depo создаёт по ним документы в DEPO и отвечает событиями; bj ведёт статусы, документ и ошибки. +
    + +{{if .Flash}}
    {{.Flash}}
    {{end}} + +
    +

    Как идёт обмен

    +

    + FTV_M2M bj записывает перевод (шапка + строки по бумагам) и событие BJ_* со STATE=TODO → + bj2depo процедура FTV_M2M_JOB ({{.JobMode}}) читает события, создаёт документ 16/16-1 в DEPO, отвечает DEPO_ORDER_CREATED (номер документа) или DEPO_ERROR → + документ bj следит за состоянием документа (OD_DOCS: начальный ввод → конец обработки / отмена) и при отмене оповещает → + события НРД по ответам MS010 и M2M bj дописывает BJ_ORDER_ACCEPTED / BJ_EXECUTED / BJ_REJECTED / BJ_CANCELLED. + Опрос таблиц — раз в минуту. Подробности и словарь событий — страница для программиста Fancy. +

    +
    + Переводов: {{.Sum.Total}} + Ждут job: {{.Sum.Pending}} + С документом DEPO: {{.Sum.WithDoc}} + С ошибками: {{.Sum.Errors}} + {{if not .Sum.LastEventAt.IsZero}}Последнее событие: {{wall .Sum.LastEventAt}}{{end}} + Справочник НРД в DEPO: {{if .Sum.HasHandbook}}{{.Sum.Handbook}} участников{{if not .Sum.HandbookAt.IsZero}} (обновлён {{wall .Sum.HandbookAt}}){{end}}{{else}}таблица FTV_M2M_HANDBOOK не создана{{end}} + Job bj2depo: {{.JobMode}}{{if eq .Fancy.DepoJobMode "bj"}}{{if .Fancy.DepoJobLast}} · последний вызов: {{.Fancy.DepoJobLast}}{{if not .Fancy.DepoJobLastAt.IsZero}} ({{msk .Fancy.DepoJobLastAt}}){{end}}{{end}}{{else if .Fancy.DepoJobCheck}} · проверка готовности: {{if .Fancy.DepoJobCheckOK}}OK{{else}}не пройдена{{end}}{{if not .Fancy.DepoJobCheckAt.IsZero}} ({{msk .Fancy.DepoJobCheckAt}}){{end}}{{end}} · настройки +
    +
    + +{{if or .Failures (not .RunAsOK)}}
    +

    Обработчик DEPO не смог обработать записи{{if .Failures}} ({{len .Failures}}){{end}}

    +

    Пока причина не устранена, документы в DEPO не создаются, а статусы по заявкам ЛК не ставятся. Устраните причину по тексту ошибки и верните записи в очередь: обработчик возьмёт их заново.

    + {{if not .RunAsOK}}

    ✗ {{.RunAsMsg}}

    {{end}} + {{if .Failures}} +
    + + {{range .Failures}} + + + {{end}} +
    ЗаписьПереводТип / строкаКогдаОшибка обработчика
    {{.TableRu}} №{{.ID}}{{.GUID}}{{.Type}}{{if .Ref}}
    {{.Ref}}
    {{end}}
    {{if not .At.IsZero}}{{wall .At}}{{end}}{{.Comment}}
    +
    + {{end}} +
    {{end}} + +
    +

    Переводы в DEPO ({{len .Rows}})

    + {{if .Error}}

    {{.Error}}

    {{end}} + {{if .Rows}} +
    + + + + {{range .Rows}} + + + + + + + + + + + + {{end}} + +
    СозданПереводНаправлениеДепонентКонтрагентБумагиДокумент DEPOСостояние
    {{if not .CreatedAt.IsZero}}{{wall .CreatedAt}}{{else}}—{{end}}{{short 8 .GUID}}{{if .Comment}}
    {{.Comment}}
    {{end}}
    {{.DirectionRu}}{{.Deponent}}{{if .DeponentDoc}}
    {{.DeponentDoc}}
    {{end}}
    {{.CP}}{{range .Lines}}
    {{.ISIN}}{{if .SecCode}} {{.SecCode}}{{end}} × {{.Amount}}{{if eq .State "ERROR"}} ошибка{{end}}
    {{else}}—{{end}}
    {{if .Doc}}№{{.Doc}}
    {{.DocState}}{{if not .DocAt.IsZero}} · {{wall .DocAt}}{{end}}
    {{else}}не создан{{end}}
    {{stageText .}} + {{range .Errors}}
    {{.}}
    {{end}}
    {{range .Events}}{{.Type}}{{if .Ref}} {{.Ref}}{{end}} · {{wall .At}}{{if .Codes}} · {{.Codes}}{{end}}{{if .Comment}} — {{.Comment}}{{end}}{{else}}событий нет{{end}}
    +
    + {{else if not .Error}} +

    В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M или при прогоне «Тестирование M2M → выгрузка истории в DEPO».

    + {{end}} +
    + +

    bj ↔ bj2depo: события, таблицы, действия job →

    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_handbook.html b/internal/lkgateway/web/templates/admin_handbook.html new file mode 100644 index 0000000..e3b7021 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_handbook.html @@ -0,0 +1,105 @@ +{{define "content"}} +
    +

    Справочник участников M2M

    +

    Актуальная карта участников сервиса MOEX МОСТ (получена от НРД пакетом #M2MTH): ИНН, код участника (MC/EC/…), тип и наименование. Используется для сопоставления контрагента с кодом участника. + {{if .At}}
    Справочник обновлён: {{.At}}.{{end}} +

    + {{if not .Participants}} +
    + Справочник ещё не загружен. +

    Запросите его у НРД: Настройка → Тесты → «Запросить справочник у НРД». Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.

    +
    + {{end}} +
    + +{{if .Participants}} +
    +
    +
    + +
    +
    + + +
    +
    + +
    + + + + + + + + + + + {{range .Participants}} + + + + + + + {{end}} + +
    Код участникаИННТипНаименование
    {{if .Code}}{{.Code}}{{else}}—{{end}}{{.INN}} + {{if eq .Kind "depo"}}депозитарий + {{else if eq .Kind "broker"}}брокер + {{else if eq .Kind "place"}}место расчётов + {{else}}{{.Kind}}{{end}} + {{.Name}}
    +
    + +
    + + + +{{end}} + +
    +
    + Коды ошибок и отказов M2M — справка +
    {{.CodesHTML}}
    +
    +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_help.html b/internal/lkgateway/web/templates/admin_help.html index ed8c952..eab567e 100644 --- a/internal/lkgateway/web/templates/admin_help.html +++ b/internal/lkgateway/web/templates/admin_help.html @@ -41,5 +41,17 @@

    Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.

    + +
    +

    bj ↔ bj2depo: трансферные таблицы и события — для программиста Fancy →

    +

    FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS: какие идентификаторы даёт bj, словарь событий в обе стороны (BJ_* и DEPO_*), правила STATE и идемпотентности, порядок по этапам, примеры SQL, открытые вопросы.

    +
    +
    + +
    +

    QUIK МНП: подготовка к M2M — для администратора QUIK →

    +

    Чек-лист для QUIK Administrator: классы INSTROUTM2M / INSTRINM2M, инструменты, включение типов «Ввод/Вывод ЦБ М2М», права технологического пользователя, перезапуск модуля. Со ссылками на страницы документов ARQA и разбором ошибок.

    +
    +
    {{end}} diff --git a/internal/lkgateway/web/templates/admin_help_architecture.html b/internal/lkgateway/web/templates/admin_help_architecture.html index 28edeec..391e57d 100644 --- a/internal/lkgateway/web/templates/admin_help_architecture.html +++ b/internal/lkgateway/web/templates/admin_help_architecture.html @@ -126,11 +126,11 @@

    Что у нас уже готово

      -
    • ✅ REST-клиент ИШ в internal/nsdadapter/igw/ — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS
    • -
    • ✅ Робот-эмулятор в internal/nsdadapter/mock/ — позволяет проверить нашу логику до получения реального ИШ
    • -
    • ✅ Конфигурация в админке — поля igw_base_url и channel в /admin/setup; авто-определение профилей GUEST/TEST3/PROD
    • -
    • ✅ Подбор URL контуров — при выборе профиля URL ONYX заполняется автоматически
    • -
    • ✅ Полная документация ИШ в DOC/ и дистрибутив в dist/ish/
    • +
    • REST-клиент ИШ в internal/nsdadapter/igw/ — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS
    • +
    • Робот-эмулятор в internal/nsdadapter/mock/ — позволяет проверить нашу логику до получения реального ИШ
    • +
    • Конфигурация в админке — поля igw_base_url и channel в /admin/setup; авто-определение профилей GUEST/TEST3/PROD
    • +
    • Подбор URL контуров — при выборе профиля URL ONYX заполняется автоматически
    • +
    • Полная документация ИШ в DOC/ и дистрибутив в dist/ish/
    {{end}} diff --git a/internal/lkgateway/web/templates/admin_help_crypto.html b/internal/lkgateway/web/templates/admin_help_crypto.html index 1caec76..e115795 100644 --- a/internal/lkgateway/web/templates/admin_help_crypto.html +++ b/internal/lkgateway/web/templates/admin_help_crypto.html @@ -5,15 +5,15 @@

    Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль. На Linux рабочий local.gdbm нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.

    -
    +

    Почему профиль готовится на Windows, а не на сервере

    Боевой Astra Linux SE-сервер с ГОСТ-криптографией обязан быть headless: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.

    Это стандартная практика в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.

    Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах zcs/vdcsp_cfg не должны запускаться.

    -
    -

    ✅ Подготовка профиля (Windows → USB → bj-server)

    +
    +

    Подготовка профиля (Windows → USB → bj-server)

    Шаг A — на компьютере под Windows

      @@ -69,7 +69,7 @@
      1. Вставьте флешку в сервер (физический USB-порт или прокинутая через гипервизор виртуальная флешка).
      2. -
      3. Откройте /admin/setup. Через 2-3 секунды (автодетект монтирования) в блоке «Носители ключей» появится строка 🔌 USB /run/media/.... Внутри неё — сабблок «Профиль Валидаты» с тремя строками: local.pse / local.gdbm / *.vdk.
      4. +
      5. Откройте /admin/setup. Через 2-3 секунды (автодетект монтирования) в блоке «Носители ключей» появится строка USB /run/media/.... Внутри неё — сабблок «Профиль Валидаты» с тремя строками: local.pse / local.gdbm / *.vdk.
      6. В поле «Имя профиля» введите осмысленное имя (например moex) и нажмите «Импортировать профиль в Валидату».
        Сервер скопирует файлы в /var/lib/bj/profiles/<имя>/, допишет секцию в /opt/Validata/VDCSP/etc/pki1.conf. Toast подтвердит: «Секция дописана в pki1.conf».
      7. diff --git a/internal/lkgateway/web/templates/admin_help_fancy_events.html b/internal/lkgateway/web/templates/admin_help_fancy_events.html new file mode 100644 index 0000000..d4ef85a --- /dev/null +++ b/internal/lkgateway/web/templates/admin_help_fancy_events.html @@ -0,0 +1,243 @@ +{{define "content"}} +
        +

        bj ↔ bj2depo: трансферные таблицы FTV_M2M и события

        +

        Для разработчика Fancy (сервис bj2depo). Как bj заполняет FTV_M2M / FTV_M2M_LINES, какими идентификаторами располагает, какие события пишет в FTV_M2M_EVENTS и какие ожидает в ответ. Согласовано 10.09.2026 и реализовано в bj: события BJ_* пишутся, читаются DEPO_ORDER_CREATED и DEPO_ERROR, справочник участников перезаписывается при каждом получении.

        +
        + +
        + + +
        + +

        Кто что делает

        + + + + + + +
        СервисРоль
        bj (Bridge-and-Join-s)обмен с НРД по MOEX МОСТ (M2MTR / M2MTD / M2MER, депо-поручения 16 и 16/1, отчёты MS010), сверка с DEPO/SPECTRE, блокировка и движение бумаг в QUIK МНП, личный кабинет. Пишет поручения и события в трансферные таблицы, читает события от DEPO.
        bj2depo (джоба Fancy)читает FTV_M2M / FTV_M2M_LINES → создаёт и меняет сущности DEPO (M2M-поручения D_CLISER_M2M, строки D_CLISER_M2M_DOL); обрабатывает события FTV_M2M_EVENTS от bj; пишет свои события для bj.
        +

        Работаем только с НРД (MOEX МОСТ), СПБ нет. Переводятся только выпуски с ISIN, начинающимся на RU. База — наша копия DEPO на SQL002 (BJ_DEPO_DATA); джобу bj2depo нужно поднять на ней (сейчас она работает на SQL001/UPD_DE_DATA, и записи в нашей базе никто не забирает).

        + +

        Какими идентификаторами располагает bj

        + + + + + + + + + + + +
        СущностьЧто есть у bjЧто можем передавать в таблицы
        ДепонентФИО + серия/номер паспорта из сообщения НРД. bj сам находит лицо в DEPO по паспорту (серия без пробелов, OD_FACES.DOC_SER/DOC_NUM).DEPONENT_NAME, DEPONENT_DOC и DEPONENT_ID = OD_FACES.SELF_ID (мастер-ID лица; у дублей указывает на основную запись).
        Контрагент (депозитарий)код участника МОСТ (MC…, 12 знаков), депозитарный код НРД (HL…), ИНН, наименование из справочника участников M2M. В OD_FACES депозитариев-контрагентов нет (проверено по ИНН), есть только сам НКО АО НРД (ID 18174).CONTRAGENT_M2M_CODE; при желании ИНН и наименование — доп. поля.
        БумагаISIN + код НРД (напр. SBER/03); bj находит OD_VALUES.ID по ISIN (IS_IN=1 = принята на обслуживание).ISIN, SEC_CODE и SHARE = OD_VALUES.ID.
        Счёт клиентаbj находит открытый торговый счёт ВТНКЦ… (OD_CLIENTS, STATE=0). При нескольких договорах берётся самый ранний (правило депозитария).OD_CLIENTS.ID и NUMBER.
        Счёт депозитария (место хранения)наш счёт в НРД HL171004001C + раздел 36MC04136…. В DEPO это запись OD_CLIENTS с NUMBER HL171004001C/253400M18U5TB02TW421/253400_X1A47/<раздел> (например ID 79 для раздела 36MC0413600000F00). OD_CLIENTS_REC.LINK_NUMBER у нас пустой везде.PLACE_NUMBER = раздел (или ID записи OD_CLIENTS, если удобнее).
        Причины отказакоды M2M01…M2M10 по каждой бумаге (может быть несколько), при отказе НРД по поручению 16/16-1 — код и текст из MS010.M2M_REJECT_CODE через ;, текст — в COMMENT.
        КлючиGUID перевода (из M2MTR) и REFERENCE строки (референс бумаги из M2MTR, M2M…).как ключи FTV_M2M / FTV_M2M_LINES / FTV_M2M_EVENTS.
        + +

        Таблицы (как согласовано, поля, которые заполняет bj)

        + + + + + + + +
        ТаблицаПоля от bj
        FTV_M2MGUID, DIRECTION (IN — к нам зачисляют, OUT — мы списываем), CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID (если нашли), COMMENT. Вставка вместе со строками в одной транзакции.
        FTV_M2M_LINESGUID, REFERENCE, ISIN, SEC_CODE, SHARE (если нашли), AMOUNT (штуки, дробные для паёв), PLACE_NUMBER.
        FTV_M2M_EVENTSGUID, REFERENCE (пусто = по поручению целиком), EVENT_TYPE, EVENT_DATE, M2M_REJECT_CODE, COMMENT, STATE (см. правила).
        +

        Поручение создаётся в DEPO на самом раннем этапе: сразу после записи в FTV_M2M (для входящих — как только пришёл M2MTR; для исходящих — как только наш M2MTR ушёл в НРД).

        + +

        Правила для событий

        +
          +
        • Одно событие — одна строка. Ключ идемпотентности: GUID + REFERENCE + EVENT_TYPE; повтор не пишется.
        • +
        • Направление по префиксу типа: BJ_* пишет bj и читает bj2depo; DEPO_* пишет bj2depo и читает bj. Если удобнее, вместо префикса — колонка SOURCE.
        • +
        • STATE — состояние обработки получателем: пишущая сторона ставит TODO; получатель после обработки — SUCCESS или ERROR, в COMMENT — что сделано или почему не вышло; TimeBeg/TimeEnd — время обработки.
        • +
        • Порядок: события одного GUID обрабатываются по ID. Событие по строке (REFERENCE задан) относится к одной бумаге; без REFERENCE — ко всему поручению.
        • +
        • Коды отказа в M2M_REJECT_CODE через ;, как их даёт НРД (M2M01…M2M10), человекочитаемый текст — в COMMENT.
        • +
        + +

        События от bj (обрабатывает bj2depo)

        + + + + + + + + + + + + + + + + + +
        EVENT_TYPEКогда bj пишетУровеньЧто ожидаем от bj2depo
        BJ_RECEIVEDпришёл входящий M2MTR от другого депозитария (мы зачисляем); одновременно вставлены FTV_M2M + LINESпоручениесоздать M2M-поручение в DEPO, вернуть DEPO_ORDER_CREATED
        BJ_SENTнаш исходящий M2MTR ушёл в НРД (мы списываем)поручението же для списания
        BJ_DECISION_CONFIRM / BJ_DECISION_REJECTмы ответили контрагенту по бумаге (M2MTD); при отказе — кодыстрокапроставить статус строки: принято / отказано с кодами
        BJ_CP_CONFIRM / BJ_CP_REJECTконтрагент ответил по нашему исходящему (M2MTD)строкато же для списания
        BJ_SERVICE_ERRORсервис МОСТ отверг перевод (M2MER ERROR), код M2Mxxпоручениезакрыть поручение как отклонённое
        BJ_ORDER_SENTподано депо-поручение 16 (списание) или 16/1 (зачисление) в НРД; в COMMENT — CONTR_D_ID и датапоручениепривязать номер поручения НРД
        BJ_ORDER_ACCEPTED / BJ_ORDER_REJECTEDНРД принял (отчёт MS010) или отверг наше депо-поручение; при отказе — код и текстпоручениепроставить результат; при принятии — провести движение по счёту, если DEPO делает это по нашему сигналу (см. вопросы)
        BJ_ORDER_LATEMS010 не получен за 5 минут после подачи (регламент)поручениедля информации / контроля
        BJ_EXECUTEDперевод исполнен (по отчёту НРД)поручениезакрыть как исполненное
        BJ_CANCELLEDперевод отозван / аннулирован / истёк срок 30 дней без исполненияпоручениезакрыть как отменённое
        BJ_GATEWAY_REJECTшлюз bj отказал в исходящем переводе из ЛК до отправки в НРД (сверка: лицо, счёт, бумага, остаток, место хранения, блокировки; либо данные заявки некорректны). Код в M2M_REJECT_CODE (M2Mxx или QTY/PLACE/QUIK), причина в COMMENT; одновременно вставлены FTV_M2M (OUT) + LINESстроказакрыть строку и документ как отклонённые (см. «Действия джобы»); пометить событие SUCCESS или ERROR — только после этого bj ставит заявке в ЛК «Отклонена»
        BJ_PINGпроверка готовности обработчика из настроек bj (GUID случайный, без шапки в FTV_M2M)служебноеничего не делать, только пометить SUCCESS — bj ждёт отметку до 40 с и удаляет строку
        BJ_QUIK_BLOCKED / BJ_QUIK_RELEASED / BJ_QUIK_SIGNED / BJ_QUIK_DEPOSITEDблокировка / снятие / списание / зачисление в QUIK МНП; номер поручения МНП в COMMENTстрокатолько для сверки QUIK ↔ DEPO, в DEPO ничего не менять
        + +

        События от bj2depo (обрабатывает bj)

        +

        Согласовано: bj2depo пишет только DEPO_ORDER_CREATED и DEPO_ERROR. Остальные типы из таблицы ниже (проверка, исполнение, отмена) остаются в резерве на случай, если понадобятся позже; bj их пока не ждёт.

        + + + + + + +
        EVENT_TYPEКогда bj2depo пишетУровеньЧто делает bj
        DEPO_ORDER_CREATEDпоручение в DEPO создано; номер документа в COMMENT (и/или FANSY_DOC в FTV_M2M)поручениепоказывает номер в карточке перевода, далее ссылается на него
        DEPO_ERRORджоба не смогла обработать событие или поручение (текст)любойоповещение оператору bj
        +

        Согласовано с программистом Fancy: от bj2depo достаточно двух событий, DEPO_ORDER_CREATED и DEPO_ERROR. Проверку строк по регламенту делает bj (сверка с DEPO), состояние документа bj читает сам (раздел «Обратная связь в bj»), поэтому отдельные события о проверке, исполнении и отмене не нужны.

        + +

        Порядок событий по этапам

        + + + + + + + + + + + +
        Входящий перевод (мы зачисляем)Исходящий перевод (мы списываем)
        1. bj: FTV_M2M + LINES, BJ_RECEIVED1. bj: QUIK блокирует бумаги (BJ_QUIK_BLOCKED), M2MTR в НРД, FTV_M2M + LINES, BJ_SENT
        2. bj2depo: DEPO_ORDER_CREATED; bj сверяет строки с DEPO2. bj2depo: DEPO_ORDER_CREATED
        3. bj: ответ контрагенту — BJ_DECISION_CONFIRM / BJ_DECISION_REJECT3. bj: ответ контрагента — BJ_CP_CONFIRM / BJ_CP_REJECT (при отказе QUIK снимает блокировку: BJ_QUIK_RELEASED)
        4. bj: подача 16/1 — BJ_ORDER_SENT4. bj: подача 16 — BJ_ORDER_SENT
        5. bj: BJ_ORDER_ACCEPTED (MS010) или BJ_ORDER_REJECTED; QUIK зачисляет — BJ_QUIK_DEPOSITED5. bj: BJ_ORDER_ACCEPTED / BJ_ORDER_REJECTED; QUIK списывает — BJ_QUIK_SIGNED
        6. bj2depo: движение по счёту депо по BJ_ORDER_ACCEPTED / BJ_EXECUTED, документ в «Конец обработки»6. bj2depo: то же, списание
        Отклонения: BJ_GATEWAY_REJECT (шлюз bj отказал до отправки в НРД; статус в ЛК — после обработки джобой), BJ_SERVICE_ERROR, BJ_ORDER_LATE (5 мин без MS010), BJ_CANCELLED (30 дней без исполнения), DEPO_ERROR.
        + + + +

        Действия джобы по событиям: что делать с документом DEPO

        +

        Сейчас джоба создаёт документ по шапке и помечает события обработанными, но документ и его строки не меняются: после отказа документ остаётся в состоянии «Начальный ввод» и его приходится отменять руками, а после приёма остаток на счёте появляется, но документ в конечное состояние не переходит (проверено 14.09.2026 на документах 75070271 и 75070273). Ниже — что джобе нужно делать по каждому событию. Документ всегда находится по GUID, строка — по GUID + REFERENCE; событие без REFERENCE относится ко всему документу.

        + + + + + + + + + + + + + +
        СобытиеУровеньЧто сделать в DEPOКонечное состояние
        BJ_RECEIVED, BJ_SENTдокументдокумент уже создан по шапке; ничего не делать, пометить SUCCESS«Начальный ввод» (0) → можно «Начало обработки» (1)
        BJ_DECISION_REJECT (входящий: мы отказали), BJ_CP_REJECT (исходящий: отказал контрагент)строкав D_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE в M2M_STATUS/M2M_TEXT (текст из COMMENT). Если отказаны все строки документа — выполнить шаг «Отмена» (тип шага 0→REJ), как это делает оператор вручнуюстрока «отказ, M2Mxx»; документ «Отмена обработки» (3)
        BJ_DECISION_CONFIRM, BJ_CP_CONFIRMстрокаотметить строку как принятую (M2M_STATUS «принято»); движения по счёту пока нет — ждём поручение НРДстрока «принято»; документ «Начало обработки» (1)
        BJ_ORDER_SENTдокументзаписать номер депо-поручения НРД (CONTR_D_ID из COMMENT); состояние — ожидание«Ожидание доставки (ответа)» (10) или «Ожидание исполнения операции» (127)
        BJ_ORDER_ACCEPTED + BJ_EXECUTEDдокументпровести движение по счёту депо по всем принятым строкам: входящий — зачисление на торговый счёт клиента (то, что оператор сделал вручную 14.09 для 75070271: появился остаток 10 SBER на 836), исходящий — списание; затем закрыть документ«Конец обработки (в архиве)» (2)
        BJ_GATEWAY_REJECTстрокав D_CLISER_M2M_DOL строки записать код из M2M_REJECT_CODE и причину из COMMENT; если отказаны все строки — шаг «Отмена». Документ мог не создаться (например, бумаги с таким ISIN нет в OD_VALUES) — тогда достаточно пометить событие ERROR с текстом причиныстрока «отказ шлюза»; документ «Отмена обработки» (3)
        BJ_ORDER_REJECTED, BJ_SERVICE_ERROR, BJ_CANCELLEDдокументзаписать причину (COMMENT / код) в документ, выполнить шаг «Отмена»«Отмена обработки» (3)
        BJ_ORDER_LATEдокументтолько пометка/комментарий («отчёт НРД не получен за 5 минут»); состояние не менятьбез изменений
        BJ_QUIK_*, BJ_PINGслужебныеничего не делать, пометить SUCCESSбез изменений
        +

        Названия и коды состояний — из справочника OD_DOC_STATES (D_CAT=0): 0 «Начальный ввод», 1 «Начало обработки (зарегистрирован)», 2 «Конец обработки (в архиве)», 3 «Отмена обработки», 10 «Ожидание доставки (ответа)», 127 «Ожидание исполнения операции». Шаг отмены, который выполнял оператор: OD_STEP_TYPES ID 75069472 «Отмена» (SYS_NAME 0->REJ, OUT_STATE 3).

        +

        Запрос, которым джоба находит отказы вместе с документом и строкой:

        +
        SELECT e.ID, e.GUID, e.REFERENCE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC, l.DOL
        +FROM dbo.FTV_M2M_EVENTS e
        +JOIN dbo.FTV_M2M m ON m.GUID = e.GUID
        +JOIN dbo.FTV_M2M_LINES l ON l.GUID = e.GUID AND l.REFERENCE = e.REFERENCE
        +WHERE e.STATE = 'TODO' AND e.EVENT_TYPE IN ('BJ_DECISION_REJECT', 'BJ_CP_REJECT')
        +ORDER BY e.ID;
        +

        Для событий по документу целиком (REFERENCE пуст) джойн со строками не нужен:

        +
        SELECT e.ID, e.GUID, e.EVENT_TYPE, e.M2M_REJECT_CODE, e.COMMENT, m.DOC
        +FROM dbo.FTV_M2M_EVENTS e
        +JOIN dbo.FTV_M2M m ON m.GUID = e.GUID
        +WHERE e.STATE = 'TODO' AND e.REFERENCE IS NULL
        +ORDER BY e.ID;
        +

        Обратная связь в bj. bj сам читает состояние документа по номеру (OD_DOCS.STATE по FTV_M2M.DOC) и показывает его в карточке перевода; при ручной отмене в DEPO оператор bj получает оповещение. Поэтому отдельные события от джобы о смене состояния не требуются — достаточно, чтобы документ переходил в состояния из таблицы выше.

        + +

        Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK

        +

        НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда CONTRAGENT_M2M_CODE из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.

        +

        Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — MC0413600000.

        + + + + + + + + + + + + + + +
        Поле таблицыТипОткуда у bj / что означает
        INNvarchar(12)ИНН организации. Вместе с PARTICIPANT_CODE и KIND — ключ строки.
        PARTICIPANT_CODEvarchar(12)код участника МОСТ (MC…, 12 знаков) — тот самый CONTRAGENT_M2M_CODE. У мест расчётов пусто.
        KINDvarchar(8)DEPO — депозитарий, BROKER — брокер (одна организация может быть в обоих качествах — две строки), PLACE — место расчётов.
        NAME_FULLnvarchar(255)полное наименование (рус.)
        NAME_SHORTnvarchar(128)краткое наименование (рус.)
        NAME_DISPLAYnvarchar(255)отображаемое наименование (рус.) — то, что показываем в интерфейсах
        NAME_ENGnvarchar(255)наименование на английском (есть не у всех)
        HANDBOOK_ATdatetimeдата/время версии справочника НРД (CreationTimestamp)
        UPDATED_ATdatetimeкогда bj записал строку
        ACTIVEbitвсегда 1 (история не ведётся, таблица перезаписывается целиком)
        +

        Обновление (согласовано). История не нужна: при каждом получении справочника от НРД bj удаляет все строки и записывает содержимое заново одной транзакцией (DEPO не видит пустую таблицу; DELETE вместо TRUNCATE, потому что TRUNCATE требует права ALTER) — аналог нового HANDBOOK.xml. Дополнительно перезапись повторяется при старте bj и раз в 6 часов. Просьба создать таблицу по скрипту ниже (создаёт наша сторона: у логина bj нет права CREATE TABLE в BJ_DEPO_DATA). Поля — как в описании; bj2depo использует INN, PARTICIPANT_CODE, NAME_SHORT. Английские наименования и места расчётов bj пишет, джоба их не использует.

        +

        Примеры реальных строк:

        + +
        INNPARTICIPANT_CODEKINDNAME_DISPLAY
        7707025725MC0005600000DEPOАО АКБ "ЦентроКредит"
        9714012520MC0418000000DEPOИК ФИАНИТ
        7831000034—PLACEАО "СПБ Банк"
        +

        Как это связано с остальным: в FTV_M2M поле CONTRAGENT_M2M_CODE = PARTICIPANT_CODE строки с KIND=DEPO; ИНН отправителя/получателя в сообщениях НРД (TransferringDepository/ReceivingDepository INN) = INN. Тот же справочник bj загружает в QUIK МНП (таблица M2MDepoRecipients: ИНН + наименование).

        + +

        Кто запускает обработку: джоба SQL Agent или bj

        +

        Обработка трансферных таблиц — это вызов процедуры FTV_M2M_JOB в базе DEPO. Есть два равноценных способа её запускать:

        + + + + + + +
        СпособЧто нужно на сервере клиентаПлюсы / минусы
        A. Джоба SQL Agent bj2depo (как сейчас у нас: шаг exec FTV_M2M_JOB, каждые 10 с)SQL Agent запущен; права на создание джобы (SQLAgentOperatorRole / sysadmin); учётка Agent с правами на BJ_DEPO_DATAработает независимо от bj; но у каждого клиента нужно заводить джобу и права, а bj не видит её состояние без доступа к msdb
        B. bj вызывает процедуру сам (Настройка → Обмен Fancy → «Обработка трансферных таблиц DEPO»)только GRANT EXECUTE ON dbo.FTV_M2M_JOB TO <логин bj>одна настройка на все внедрения, состояние и ошибки видны в bj; обработка идёт, только пока работает bj
        +

        Включать нужно один вариант, иначе процедура будет вызываться параллельно из двух мест. Просьба к разработчику: сделать FTV_M2M_JOB безопасной при повторном/параллельном вызове (обработка только строк со STATE='TODO', блокировка строки на время обработки) — тогда случайное двойное включение не навредит.

        + + +

        Результат выгрузки истории тестов (14.09.2026)

        +

        Кнопкой «Выгрузить историю в DEPO» на вкладке «Тестирование» bj записал в трансферные таблицы все переводы, проведённые на TEST3 (робот НРД и банк Зенит): 78 переводов, 252 события с реальными датами. Джоба bj2depo создала по каждому документ в DEPO (D_CLISER_M2M + строки), сама определила контрагента, депонента, бумагу и место хранения. Что при этом выяснилось:

        +
          +
        • События по строкам (BJ_DECISION_*, BJ_CP_*, с REFERENCE) джоба обрабатывает — SUCCESS.
        • +
        • События по поручению целиком (REFERENCE пуст: BJ_SENT, BJ_RECEIVED, BJ_SERVICE_ERROR, BJ_ORDER_SENT, BJ_ORDER_ACCEPTED, BJ_EXECUTED) джоба отвергает с текстом «Не найдена строка M2M-поручение по GUID + REFERENCE». Просьба: для событий без REFERENCE применять действие к документу (или ко всем его строкам).
        • +
        • После обработки событий статусы в DEPO не меняются: у всех документов DOC_STATUS=0, у строк M2M_STATUS=0, DEP_STATUS=0. Просьба согласовать отображение, например: BJ_CP_REJECT / BJ_DECISION_REJECT → строка «отказ» с кодом в M2M_TEXT; BJ_CP_CONFIRM / BJ_DECISION_CONFIRM → «принято»; BJ_ORDER_ACCEPTED / BJ_EXECUTED → документ «исполнен»; BJ_SERVICE_ERROR / BJ_CANCELLED → документ «отклонён / отменён». Тогда историю тестов можно будет просмотреть в DEPO с настоящими статусами.
        • +
        • Событие BJ_PING сейчас помечается ERROR — достаточно помечать SUCCESS без действий.
        • +
        + +

        Примеры SQL

        +

        Так bj заведёт входящий перевод (одна транзакция):

        +
        BEGIN TRAN;
        +INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC, DEPONENT_ID, COMMENT)
        +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Погонщикова Виктория Олеговна', '4500 123456', 11773497, N'M2MTR от MC0034900000');
        +INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER)
        +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00');
        +INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT)
        +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'BJ_RECEIVED', GETDATE(), N'входящий перевод получен');
        +COMMIT;
        +

        Так bj2depo сообщает, что документ в DEPO создан:

        +
        INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, STATE, EVENT_TYPE, EVENT_DATE, COMMENT)
        +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'TODO', 'DEPO_ORDER_CREATED', GETDATE(), N'75070271');
        +

        Так bj забирает свои события и подтверждает обработку:

        +
        SELECT ID, GUID, REFERENCE, EVENT_TYPE, M2M_REJECT_CODE, COMMENT
        +FROM dbo.FTV_M2M_EVENTS WHERE EVENT_TYPE LIKE 'DEPO_%' AND STATE = 'TODO' ORDER BY ID;
        +UPDATE dbo.FTV_M2M_EVENTS SET STATE = 'SUCCESS', TimeBeg = GETDATE(), TimeEnd = GETDATE(), COMMENT = N'применено в bj' WHERE ID = @id;
        + +

        Структура таблицы справочника и то, как bj её перезаписывает:

        +
        CREATE TABLE dbo.FTV_M2M_HANDBOOK (
        +  ID int IDENTITY(1,1) PRIMARY KEY, INN varchar(12) NOT NULL, PARTICIPANT_CODE varchar(12) NULL, KIND varchar(8) NOT NULL,
        +  NAME_FULL nvarchar(255) NULL, NAME_SHORT nvarchar(128) NULL, NAME_DISPLAY nvarchar(255) NULL, NAME_ENG nvarchar(255) NULL,
        +  HANDBOOK_AT datetime NULL, UPDATED_AT datetime NOT NULL DEFAULT GETDATE(), ACTIVE bit NOT NULL DEFAULT 1);
        +-- при каждом получении справочника от НРД (одна транзакция):
        +BEGIN TRAN;
        +DELETE FROM dbo.FTV_M2M_HANDBOOK;
        +INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_FULL, NAME_SHORT, NAME_DISPLAY, NAME_ENG, HANDBOOK_AT, UPDATED_AT, ACTIVE)
        +VALUES ('7707025725', 'MC0005600000', 'DEPO', N'…', N'АО АКБ "ЦентроКредит"', N'…', N'…', @hb_at, GETDATE(), 1); -- и так по всем участникам
        +COMMIT;
        + +

        Открытые вопросы

        +

        Закрыто 15.09.2026:

        +
          +
        • от bj2depo достаточно DEPO_ORDER_CREATED и DEPO_ERROR; DEPO_EXECUTED не нужен, bj его не ждёт;
        • +
        • справочник: поля как в описании, джоба использует INN / PARTICIPANT_CODE / NAME_SHORT, история не нужна, перезапись целиком; таблицу создаёт наша сторона;
        • +
        • REFERENCE для всех исходящих переводов, включая созданные из терминала QUIK, присваивает bj при формировании M2MTR (формат M2M…);
        • +
        • джоба bj2depo на SQL002 / BJ_DEPO_DATA работает (EXECUTE AS SYSDBA), логин bj — читатель базы и запись в трансферные таблицы;
        • +
        • события BJ_QUIK_* bj пишет для истории; джоба помечает их SUCCESS без действий.
        • +
        +

        Остаётся:

        +
          +
        1. Создать таблицу FTV_M2M_HANDBOOK в BJ_DEPO_DATA по скрипту из раздела «Справочник участников».
        2. +
        3. Действия джобы по событиям (раздел «Действия джобы»): движение по счёту депо по BJ_ORDER_ACCEPTED / BJ_EXECUTED, запись отказов в строки и шаг «Отмена».
        4. +
        +
        +
        +{{end}} diff --git a/internal/lkgateway/web/templates/admin_help_quik.html b/internal/lkgateway/web/templates/admin_help_quik.html new file mode 100644 index 0000000..3a209a7 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_help_quik.html @@ -0,0 +1,163 @@ +{{define "content"}} +
        +

        QUIK МНП: подготовка к переводам M2M

        +

        Инструкция для администратора сервера QUIK. Что открыть в QUIK Administrator, что создать и включить, какие права выдать, чтобы bj мог блокировать, списывать и зачислять бумаги клиентов через Модуль неторговых поручений (МНП) при переводах по MOEX МОСТ. Каждый шаг со ссылкой на раздел и страницу документа ARQA.

        +
        + +
        + + +
        + +

        Как это работает

        +

        bj не подключается к серверу QUIK напрямую. Он работает с базой данных Модуля неторговых поручений (МНП): вызывает хранимые процедуры и читает экспортные таблицы. Модуль сам передаёт поручения на сервер QUIK, проверяет позицию клиента, блокирует бумаги, исполняет или снимает поручение. Поэтому всё, что должен сделать администратор, находится в двух местах: QUIK Administrator (справочник «Классы», «Пользователи») и плагин МНП в нём же (меню «Модули»).

        + + + + + + +
        СитуацияЧто делает bj в МНПТип поручения в МНП
        Клиент переводит бумаги из нас в другой депозитарийрегистрирует поручение (QUIK блокирует бумаги) → отправляет перевод в НРД → по успеху подписывает поручение (бумаги списываются), по отказу снимает его с причиной (блокировка снимается, клиент видит причину в терминале)Вывод ЦБ (M2M), класс INSTROUTM2M
        Бумаги приходят к нам из другого депозитарияпосле зачисления в НРД регистрирует поручение по ISIN — оно исполняется сразу, бумаги появляются на позиции клиентаВвод ЦБ (M2M), класс INSTRINM2M
        +

        Эти два типа появились в МНП версии 21.4.0 и по умолчанию выключены, а нужные им классы и инструменты нужно создать вручную. Именно это и описано ниже. Основной документ — «Перевод ЦБ в MOEX МОСТ» (ARQA, v1.1, 2026), далее «МОСТ», страницы указаны по нему.

        + +

        Шаг 1. Создать классы на сервере QUIK

        +

        Где: QUIK Administrator → справочник «Классы» (это база самого сервера QUIK, не плагин). Документ: МОСТ п. 4.1, стр. 11.

        +

        Создайте два класса с параметрами:

        + + + + + + + + + +
        ПараметрКласс 1Класс 2
        КодINSTROUTM2MINSTRINM2M
        Наименование (рус)Поручения на Вывод ЦБ M2MПоручения на Ввод ЦБ M2M
        Код позиции|EQTV||EQTV|
        Тип классаПорученияПоручения
        Активные операцииВключеноВключено
        +

        Альтернатива из руководства администратора МНП (шаг 5, стр. 10): применить к базе сервера QUIK (QUIK_DB) скрипт из дистрибутива модуля scripts/mssql/add_classes_instructions_ms.sql. Важно применять его именно к базе сервера QUIK, а не к master.

        + +

        Шаг 2. Добавить классы в плагин МНП

        +

        Где: QUIK Administrator → Модули → МНП → справочник «Классы инструментов». Документ: МОСТ п. 4.1, шаг 2, стр. 11.

        +

        Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: MC0413600000 (Фондовый рынок).

        + +

        Шаг 3. Создать инструменты МНП

        +

        Где: QUIK Administrator → Модули → МНП → справочник «Инструменты». Документ: МОСТ п. 4.2, стр. 14.

        + + + + + + +
        Код инструментаПривязать к классуНазначение
        RUROUTM2MINSTROUTM2Mпсевдоинструмент поручений на вывод; через него списывается комиссия депозитария
        RURINM2MINSTRINM2Mпсевдоинструмент поручений на ввод
        +

        Коды берите ровно такие, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.

        +

        Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме MC0413600000 добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).

        + +

        Шаг 4. Включить типы поручений M2M

        +

        Где: QUIK Administrator → Модули → МНП → справочник «Типы поручений». Документ: МОСТ п. 4.3, стр. 16–22.

        +
          +
        1. Найдите типы «Ввод ЦБ М2М» и «Вывод ЦБ М2М» (созданы модулем автоматически, ID 101 и 100). Откройте каждый и установите флажок «Тип поручения доступен» — по умолчанию он снят.
        2. +
        3. У «Вывод ЦБ М2М» в списке классов, для которых доступен тип, выберите режим «Только выбранные» и класс INSTROUTM2M.
        4. +
        5. У «Ввод ЦБ М2М» — то же самое с классом INSTRINM2M.
        6. +
        7. У всех остальных типов на Вывод / Поставку ЦБ переключите классы на «Только выбранные» и убедитесь, что класс INSTROUTM2M у них выключен. Иначе по этому классу можно будет зарегистрировать обычное поручение вместо M2M.
        8. +
        9. Подписи: у «Вывод ЦБ М2М» по умолчанию одна подпись — Депозитарий. Её ставит bj после ответа НРД. Дополнительные подписи не добавляйте без согласования с нами. У «Ввод ЦБ М2М» подписей нет — поручение исполняется сразу.
        10. +
        11. Поля формы менять не нужно. Если в «Вывод ЦБ М2М» не требуется привязка к номеру договора и проверка ИИС, поля «Договор обслуживания» и «Согласие на закрытие счёта ИИС» можно скрыть (МОСТ, стр. 21).
        12. +
        + +

        Шаг 5. Права технологического пользователя модуля

        +

        Где: QUIK Administrator → справочник «Пользователи». Документ: МОСТ п. 5.1, стр. 24; руководство администратора МНП, раздел 2, шаг 6, стр. 10.

        +

        Кто это. Служебный пользователь QUIK, от имени которого модуль МНП подключается к серверу QUIK. Его логин записан в файле модуля instructions.ini, секция [QuikApi], параметр User. У него уже есть права на классы МНП (INSTR и другие); нужно добавить такие же на новые классы.

        +
          +
        1. Выдайте пользователю полные права на классы INSTROUTM2M и INSTRINM2M: «Просмотр поручений», «Активные операции», «Создание поручений», «Просмотр поручений по всем брокерам», все флажки «Виды поручений», все флажки «Право подписи».
        2. +
        3. В правах по классу «Поручения» по всем классам МНП установите флажок «Специальные операции».
        4. +
        +

        Без этих прав модуль не получает новые классы от сервера QUIK и на любое поручение M2M отвечает «Неверный тип поручения», хотя в базе всё включено.

        + +

        Шаг 6. Права клиентов и менеджеров

        +

        Документ: МОСТ п. 5.2–5.3, стр. 25–27. Для работы bj этот шаг не обязателен, но без него клиенты не смогут сами подавать «Вывод ЦБ (M2M)» из терминала и не увидят исполненные вводы.

        +
          +
        • Клиентам на класс INSTROUTM2M: «Просмотр поручений», «Клиент фирмы», «Активные операции», «Создание поручений», «Вывод бумаг», «Коды клиентов у дилера».
        • +
        • Клиентам на класс INSTRINM2M (без «Активных операций»): «Просмотр поручений», «Клиент фирмы», «Ввод бумаг», «Коды клиентов у дилера».
        • +
        • Менеджерам (только просмотр): «Просмотр поручений», «Просмотр поручений по всем брокерам», «Ввод бумаг», «Вывод бумаг».
        • +
        + +

        Шаг 7. Перезапустить модуль МНП

        +

        Типы поручений, классы и права модуль читает при старте и при подключении к серверу QUIK. Справочники (клиенты, инструменты, депозитарии) он перечитывает на ходу, а вот настройки шагов 1–5 — нет. После завершения настройки перезапустите службу МНП (обычно это делается ночью по регламенту аутсорсера). Пока модуль не перезапущен, поручения M2M отвергаются с текстом «Неверный тип поручения».

        + +

        Шаг 8. База данных МНП

        +

        Со стороны базы всё уже настроено, менять ничего не нужно. Для справки:

        +
          +
        • Логин bj состоит в ролях InstrEuImport (справочники), InstrEuBroker (регистрация, подпись, снятие), InstrEx (экспортные таблицы) — руководство администратора, стр. 13 и 16.
        • +
        • Справочник депозитариев M2M (M2MDepoRecipients) и депо-счета клиентов bj заполняет сам процедурами импорта; в QUIK Administrator этот справочник не виден (МОСТ п. 6.1, стр. 29).
        • +
        • Справочник инструментов заполняется автоматически с сервера QUIK (опция AutoFillQuikSecurities=1 в instructions.ini, руководство администратора, стр. 45) — для этого технологическому пользователю нужно право «Класс доступен» на биржевые классы, например TQBR (МОСТ п. 6.3, стр. 30).
        • +
        • По желанию: опция [General].AllowReverseExecutedInstruction=1 в instructions.ini разрешает отменять уже исполненные поручения (по умолчанию запрещено, руководство администратора, стр. 48).
        • +
        + +

        Что bj делает в МНП автоматически

        +

        После настройки цикл включается в bj: Настройка → QUIK МНП → «Автоматический цикл M2M в МНП» (главный предохранитель «Разрешить записи в МНП» и три переключателя). Что происходит при включённых переключателях:

        + + + + + + + + + +
        Событие в bjДействие в МНПЧто видит клиент в QUIK
        Исходящий перевод отправляется в НРДрегистрация «Вывод ЦБ (M2M)» до отправки; если QUIK отказал (нет позиции) — перевод в НРД не отправляетсяпоручение «Активно», бумаги заблокированы
        Принимающий депозитарий отказал по бумаге / сервис МОСТ отклонилснятие поручения с текстом причиныпоручение «Снято», причина в комментарии, блокировка снята
        НРД принял наше поручение 16 (отчёт MS010)подпись «Депозитарий» → исполнениепоручение «Исполнено», бумаги списаны
        НРД принял наше поручение 16/1 по входящему переводурегистрация «Ввод ЦБ (M2M)» по ISIN — исполняется сразупоручение «Исполнено», бумаги на позиции
        Клиент сам подал «Вывод ЦБ (M2M)» из QUIKbj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящийсвоё поручение «Активно», затем исполнено или снято с причиной
        +

        Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.

        + +

        Как проверить, что всё готово

        +
          +
        1. В bj: Настройка → QUIK МНП → «Проверить подключение». В отчёте должны быть видны процедуры sp_NewInstrSec, sp_SignInstrSec, cancel_instruction и таблица M2MDepoRecipients.
        2. +
        3. Скажите нам «проверяйте». Мы на согласованном тестовом клиенте регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.
        4. +
        5. Признак готовности в самой базе МНП: у типов 100 и 101 снят признак Disabled, у классов INSTROUTM2M / INSTRINM2M есть фирма, инструменты привязаны к классам. Это мы проверяем сами.
        6. +
        + +

        Типичные ошибки модуля и что они значат

        + + + + + + + + + +
        Ответ модуляПричинаЧто сделать
        Неверный тип поручения.Модуль не знает тип «Ввод/Вывод ЦБ М2М» или у технологического пользователя нет прав на классШаги 1, 5, 7: классы на сервере QUIK, права техпользователя, перезапуск модуля
        Не найдено описание фирмы.Поручение подано типом «Перевод ЦБ», у которого поля «Фирма списания / зачисления» берутся из «Описания» фирмы, а оно пустоеДля M2M этот тип не используется. Если нужен — заполнить «Описание» в справочнике «Фирмы» плагина МНП
        Поле 'ISIN' не поддержано для поручений с заполненным биржевым кодом класса.В вызове одновременно код класса и ISINОшибка вызова на нашей стороне, к настройке не относится
        Указан несуществующий счет ДЕПО поставщика-получателя.Обычный (не M2M) тип «Получение ЦБ» требует депо-счёт получателя в формате, который принимает только терминалДля зачислений использовать «Ввод ЦБ (M2M)», ему депо-реквизиты не нужны
        Транзакция проверяется на сервере Quik.Не ошибка: промежуточный статус, модуль ждёт ответ сервера QUIK (до минуты)Ничего
        + +

        Что сообщить нам после настройки

        +
          +
        • Что классы созданы на сервере QUIK и в плагине МНП (шаги 1–2).
        • +
        • Точные коды инструментов, если они отличаются от RUROUTM2M / RURINM2M (шаг 3).
        • +
        • Что типы 100/101 включены и привязаны к классам (шаг 4), права техпользователю выданы (шаг 5).
        • +
        • Время перезапуска модуля (шаг 7).
        • +
        • Код тестового клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.
        • +
        + +

        Документы ARQA

        + +
        +
        +{{end}} diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html index 22ba9e1..ee83243 100644 --- a/internal/lkgateway/web/templates/admin_home.html +++ b/internal/lkgateway/web/templates/admin_home.html @@ -13,49 +13,63 @@ {{end}}
    +{{if .LKUrl}} +{{/* ===== Первичный тест интеграции с ЛК ===== */}} +
    +
    + +
    +
    +
    Первичный тест с личным кабинетом
    +
    Откройте ЛК, войдите и подайте заявку M2M — она придёт к нам и появится в разделе «Переводы». Статус вернём в ЛK через callback.
    +
    + Открыть тестовый ЛК → +
    +{{end}} + {{/* ===== Крупные плитки задач ===== */}} {{/* ===== Сводка по переводам ===== */}}
    -
    +
    Всего переводов
    {{.Counts.Total}}
    -
    +
    Подтверждено
    {{.Counts.Confirmed}}
    -
    +
    В ожидании
    {{.Counts.InProgress}}
    -
    +
    Отказы / таймауты
    {{.Counts.Failed}}
    @@ -73,10 +87,10 @@ {{range .Recent}} - {{.CreatedAt.Format "15:04:05"}} - {{slice .ID 0 8}} - {{.Investor.LastName}} {{slice .Investor.FirstName 0 1}}. - {{len .Securities}} + {{.CreatedAt.Format "02.01.2006 15:04:05"}} + {{short 8 .ID}} + {{.Investor.LastName}} {{initial .Investor.FirstName}} + {{len .Securities}} {{ruState .Status}} открыть → @@ -98,11 +112,39 @@ {{range .News}}
    - {{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}} + {{if eq .Kind "maintenance"}}{{end}}{{if eq .Kind "feature"}}{{end}}{{if eq .Kind "system"}}{{end}}{{if eq .Kind "doc-update"}}{{end}}{{.Title}}
    {{if .Body}}
    {{.Body}}
    {{end}}
    {{end}}
    {{end}} + +{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД ===== */}} + {{end}} diff --git a/internal/lkgateway/web/templates/admin_incoming.html b/internal/lkgateway/web/templates/admin_incoming.html new file mode 100644 index 0000000..44a19a8 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_incoming.html @@ -0,0 +1,51 @@ +{{define "content"}} +
    +

    Входящие переводы

    + Запросы на перевод, где контрагент (или робот в сценарии 3333) — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге. +
    + +
    +

    Список ({{len .Items}})

    + {{if .Items}} + + + + {{range .Items}} + + + + + + + + + {{end}} + +
    ПолученGUIDОт когоЦБСтатус
    {{msk .ReceivedAt}}{{short 8 .GUID}}{{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}{{$nm}}
    {{end}}{{.SenderCode}}{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}
    {{len .Securities}}{{$s := inStatus .}}{{$s.Text}}{{if $s.Outcome}} {{$s.Outcome}}{{end}}{{if .DepoSummary}}
    сверка: {{.DepoSummary}}{{end}}
    {{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →
    + {{else}} +

    Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»).

    + {{end}} +

    🔄 Страница обновляется автоматически при появлении нового входящего.

    +
    + +{{end}} diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html new file mode 100644 index 0000000..b44aa55 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_incoming_detail.html @@ -0,0 +1,381 @@ +{{define "content"}} +{{$answered := eq .It.Status "answered"}} + +

    ← Назад

    +
    +

    Входящий перевод {{short 8 .It.GUID}} + {{if $answered}}отвечено{{else}}ждёт решения{{end}} +

    + + + + {{if $answered}}{{end}} + + + + + + {{if .It.AnswerNote}}{{end}} +
    Запрос сформирован (отправитель){{msk .It.RequestCreated}}
    Забрали из шлюза (получено нами){{msk .It.ReceivedAt}}{{if not .It.RequestCreated.IsZero}} · прохождение: {{transit .It.RequestCreated .It.ReceivedAt}}{{end}}
    Ответ отправлен{{msk .It.AnsweredAt}}{{if not .It.ReceivedAt.IsZero}} · обработка: {{transit .It.ReceivedAt .It.AnsweredAt}}{{end}}
    GUID перевода{{.It.GUID}} (в нашем решении используется этот же GUID)
    От кого (передающий){{$nm := m2mParty .It.SenderCode}}{{if not $nm}}{{$nm = m2mParty .It.SenderINN}}{{end}}{{if $nm}}{{$nm}} · {{end}}{{.It.SenderCode}}{{if .It.SenderINN}} · ИНН {{.It.SenderINN}}{{end}}
    Кому (мы){{$rn := m2mParty .It.ReceiverCode}}{{if not $rn}}{{$rn = m2mParty .It.ReceivingINN}}{{end}}{{if $rn}}{{$rn}} · {{end}}{{.It.ReceiverCode}}{{if .It.ReceivingINN}} · ИНН {{.It.ReceivingINN}}{{end}}
    Инвестор (ФИО){{.It.InvestorLast}} {{.It.InvestorFirst}} {{.It.InvestorMiddle}}
    Документ{{docType .It.DocType}}{{if .It.DocSeries}} · серия {{.It.DocSeries}}{{end}}{{if .It.DocNumber}} · № {{.It.DocNumber}}{{end}}
    Результат{{if .It.Auto}}авто {{end}}{{.It.AnswerNote}}
    +
    + +
    + +
    +

    Сверка с DEPO — эмуляция ответа

    + {{if .Depo.Ran}}рекомендуем: {{.Depo.Summary}}{{end}} +
    +

    Проверки по регламенту депозитария (инвестор, торговый счёт депо, бумага на обслуживании, договор не ИИС, блокировки). Ответ контрагенту НЕ отправлен — это только рекомендация; она подставлена в форму ниже, проверьте и нажмите «Отправить решение».

    + {{if not .Depo.Ran}} +

    Сверка не выполнена: {{.Depo.Err}}

    + {{else}} + + + +
    Инвестор в картотеке{{if .Depo.Investor}}{{.Depo.Investor}}{{else}}не найден{{end}}
    Торговый счёт депо{{if .Depo.Account}}{{.Depo.Account}} — открыт{{else}}открытого ВТНКЦ-счёта нет{{end}}
    + {{range .Depo.Secs}} + +

    {{.Security}} → {{if eq .Recommend "confirm"}}принять{{else}}отказ {{.RejectCode}} ({{m2mCode .RejectCode}}){{end}}

    + + + + {{range .Checks}} + + {{end}} + +
    ПроверкаРезультат
    {{if .OK}}🟢{{else}}🔴{{end}}{{.Name}}{{if not .OK}} → {{.Code}}{{end}}{{.Detail}}
    + {{end}} + {{end}} +
    + +
    +

    Решение по бумагам ({{len .It.Securities}})

    + {{if $answered}} +

    Решение уже отправлено роботу. Ниже — что было выбрано по каждой бумаге.

    + + + + {{range .It.Securities}} + + + + + + + + {{end}} + +
    ReferenceIdКод НРД / ISINКол-воСчёт зачисленияРешение
    {{.ReferenceID}}{{.SecurityCode}}{{if .ISIN}} / {{.ISIN}}{{end}}{{.Quantity}}{{if .DeponentCode}}депонент {{.DeponentCode}}{{end}}{{if .AccountID}}
    счёт {{.AccountID}}{{end}}{{if .SectionID}} / раздел {{.SectionID}}{{end}}{{if .SettleINN}}
    расч.деп. ИНН {{.SettleINN}}{{end}}
    {{if eq .Decision "confirmed"}}принято{{else if eq .Decision "rejected"}}отказ {{m2mCode .RejectCode}}{{else}}—{{end}}
    + {{else}} +

    По каждой бумаге выберите: принять (зачислим на наш депо-счёт из настроек НРД) или отказать (с кодом отказа). Решение уйдёт роботу одним сообщением M2MTransferDecision.

    + +
    + +
    Выберите кейс — подскажу, что нужно сделать (принять или отклонить с кодом). Автоматически ничего не меняется.
    +
    + + +
    + + + + {{range .It.Securities}} + + + + + + + + + + {{end}} + +
    ReferenceIdКод НРД / ISINКол-воСчёт зачисленияПринятьОтказатьКод отказа
    {{.ReferenceID}}{{.SecurityCode}}{{if .ISIN}} / {{.ISIN}}{{end}}{{.Quantity}}{{if .DeponentCode}}депонент {{.DeponentCode}}{{end}}{{if .AccountID}}
    счёт {{.AccountID}}{{end}}{{if .SectionID}} / раздел {{.SectionID}}{{end}}{{if .SettleINN}}
    расч.деп. ИНН {{.SettleINN}}{{end}}
    + — + +
    +
    + + + +
    +
    + + + {{end}} +
    + +{{if .It.RawXML}} +
    +
    + Данные запроса — поля и сырой XML +

    Слева — те же данные из XML, разобранные по полям для удобного просмотра; ниже — исходный XML дословно.

    + {{xmlFields .It.RawXML}} +
    Сырой XML:
    +
    {{.It.RawXML}}
    +
    +
    +{{end}} + +
    + + +
    +

    Депозитарное поручение 16/1

    + {{if .OrderDone}}проведено · MS010 + {{else if .It.OrderSubmitted}}подано, ждём MS010 + {{else}}не подано{{end}} +
    +{{if .Watch}}
    ⏱ Срок по регламенту: {{.Watch.StatusRu}}
    {{end}} + + +
    +
    + ✓ +
    Черновик получен
    {{if .DraftReceived}}{{.DraftReceived}}{{else}}сформирован НРД (MF010){{end}}
    + ⬇ Черновик от НРД
    +
    +
    + {{if .It.OrderSubmitted}}✓{{else}}2{{end}} +
    Поручение подано в НРД
    {{if .It.OrderSubmitted}}{{msk .It.OrderAt}}{{else}}нужно подать{{end}}
    + {{if .It.OrderXML}}⬇ Отправленная форма 16/1{{end}}
    +
    +
    + {{if .OrderDone}}✓{{else}}3{{end}} +
    Проведено · MS010
    {{if .OrderDone}}{{if .OrderDoneAt}}{{.OrderDoneAt}}{{else}}отчёт получен от НРД{{end}}{{else if .It.OrderSubmitted}}ждём MS010 от НРД{{else}}после подачи{{end}}
    + {{if .OrderResponses}}⬇ Ответ НРД (MS010){{end}}
    +
    +
    + +
    + {{if not .It.OrderSubmitted}} +
    + +
    + {{end}} +
    + + {{if .It.OrderSubmitted}} +
    Подано {{msk .It.OrderAt}} · пакет ИШ {{.It.OrderPkgs}}{{if .It.OrderContrDID}} · № поручения {{.It.OrderContrDID}}{{end}}
    + {{if .OrderResponses}} + + + + {{range .OrderResponses}}{{end}} + +
    ВремяОтвет НРД
    {{.Time}}{{.Kind}}
    + {{else}} +

    Ответ НРД (квитанция → принятие → отчёт MS010) появится здесь. Обновите страницу.

    + {{end}} + {{if .It.OrderXML}} +
    Посмотреть отправленный XML поручения +
    {{.It.OrderXML}}
    +
    + {{end}} + {{end}} + +
    Как это работает +

    После нашего согласия на прием НРД формирует черновик поручения 16/1 (MF010). Депозитарий подаёт его в НРД (тип ЭД ORDXM) — НРД сматчит со встречным поручением 16 контрагента, проведёт зачисление и выпустит отчёт XMLRP = MS010. Кнопка «Подать» отправляет черновик через ИШ (подпись ГОСТ ставит ИШ); пока Fancy-DEPO не подключена, можно скачать черновик и обработать вручную.

    +
    +
    + + +

    + ← Все входящие +  ·  + ⬇ Выгрузка для регулятора (ZIP: отчёт + request.xml + decision.xml) +

    + + + +{{/* ===== QUIK МНП: зачисление клиенту ===== */}} +{{$back := printf "/admin/incoming/%s" .It.ID}} + +
    +

    Тестовый прогон в DEPO с реальным клиентом

    +

    Копия этого входящего (те же бумаги и решения) записывается в трансферные таблицы DEPO под новым GUID на реального клиента из картотеки — по паспорту. Джоба bj2depo создаёт по нему документ; в НРД ничего не уходит. Полный сценарий приёма: получен → приняли → подали 16/1 → НРД принял → исполнено.

    +
    + + + + + по умолчанию — Погонщикова В.О. (ВТНКЦ00000816 / BR859) +
    +
    +
    + +
    +

    QUIK МНП — зачисление бумаг клиенту

    + все поручения → +
    +{{if .DepoL}}
    🗂 DEPO (bj2depo): {{if .DepoL.DocNo}}поручение №{{.DepoL.DocNo}} создано {{msk .DepoL.DocAt}}{{if .DepoL.DocState}} · состояние в DEPO: {{.DepoL.DocState}}{{end}}{{else}}поручение ещё не создано{{end}}{{range .DepoL.Errors}}
    ошибка: {{.}}
    {{end}}
    {{end}} + +

    Когда НРД принимает наше поручение 16/1 (отчёт MS010), bj регистрирует в Модуле неторговых поручений «Ввод ЦБ (M2M)» по ISIN — оно исполняется сразу, бумаги появляются на позиции клиента в QUIK.{{if not .QuikOn}} Записи в МНП сейчас выключены (Настройка → QUIK МНП).{{end}}

    + {{if not .Quik}}
    Зачислений по этому переводу ещё нет. + {{range .It.Securities}}{{if eq .Decision "confirmed"}} +
    + {{end}}{{end}}
    {{end}} + + {{range .Quik}} +
    +
    + {{.ISIN}} × {{.Qty}} + клиент QUIK {{.ClientID}}{{if .Contract}} · {{.Contract}}{{end}}{{if .InstrNo}} · поручение МНП №{{.InstrNo}}{{end}} + {{.StateRu}} +
    + {{if .Err}}
    {{.Err}}
    {{end}} + {{if .Steps}}
    {{range .Steps}}{{msk .At}} · {{.Action}}{{if .ActionID}} #{{.ActionID}}{{end}}: {{.Reply}}{{end}}
    {{end}} +
    + {{if or (eq .State "error") (eq .State "manual") (not .InstrNo)}} +
    + {{end}} +
    +
    + {{end}} +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_journal.html b/internal/lkgateway/web/templates/admin_journal.html new file mode 100644 index 0000000..2c3628b --- /dev/null +++ b/internal/lkgateway/web/templates/admin_journal.html @@ -0,0 +1,25 @@ +{{define "content"}} +
    +

    Журнал обмена ({{len .Rows}})

    +

    Все переводы в одной ленте — входящие и исходящие. Отдельные виды: Исходящие · Входящие.

    + {{if .Rows}} + + + + {{range .Rows}} + + + + + + + + + {{end}} + +
    ВремяНаправлениеКонтрагентЦБСтатус
    {{msk .Time}}{{if eq .DirKind "out"}}↗ Исходящий{{else}}↘ Входящий{{end}}{{.Party}}{{.SecN}}{{.StatusText}}{{if .Outcome}} {{.Outcome}}{{end}}открыть →
    + {{else}} +

    Переводов пока нет.

    + {{end}} +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_keywizard.html b/internal/lkgateway/web/templates/admin_keywizard.html index 0d6a27f..851057b 100644 --- a/internal/lkgateway/web/templates/admin_keywizard.html +++ b/internal/lkgateway/web/templates/admin_keywizard.html @@ -18,7 +18,7 @@ {{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err) {{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent) {{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}"> - {{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}} + {{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}{{else}}{{add $i 1}}{{end}}
    {{$step.Title}}
    @@ -31,7 +31,7 @@ {{/* ===== Действие в зависимости от состояния ===== */}} {{if $s.Done}} -
    +

    ✓ Готово

    Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.

    {{if $s.Backup}}

    Бэкап прежнего носителя: {{$s.Backup}}

    {{end}} diff --git a/internal/lkgateway/web/templates/admin_m2mpreview.html b/internal/lkgateway/web/templates/admin_m2mpreview.html new file mode 100644 index 0000000..29527c1 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_m2mpreview.html @@ -0,0 +1,128 @@ +{{define "content"}} + + +

    ← Назад к плану

    + +
    +

    Проверка данных перед отправкой · кейс {{.Case.ID}}

    +
    {{.Case.Debit}} → {{.Case.Credit}} · клиент {{.ClientKey}} · {{.Case.Note}}
    + +
    + 🟢 корректные данные + 🔴 намеренно неверно (по плану сценария) +
    + + {{if .AlreadySent}} + + {{end}} + {{if .Positive}} + + {{else}} + + {{end}} + +

    Инвестор (владелец бумаг)

    + + + + + +
    ФИО · документ {{if .ClientBad}}🔴{{else}}🟢{{end}}{{.ClientFIO}} ({{.ClientKey}})
    {{.ClientDUL}} + {{if .ClientBad}}
    Этот клиент не заведён у зачисляющего — сработает M2M04.
    {{end}}
    + +

    Депозитарии

    + + + + + + + + + + + + + +
    Списывающий (мы) 🟢{{if .OurName}}{{.OurName}} · {{end}}{{.OurCode}}{{if .OurINN}} · ИНН {{.OurINN}}{{end}}
    Счёт списания (место расчётов) {{if eq .DefectField "account"}}🔴{{else}}🟢{{end}}{{.OurAccount}} + {{if eq .DefectField "account"}}
    {{.DefectDesc}}
    {{end}}
    Зачисляющий (контрагент) 🟢{{if .CpName}}{{.CpName}} · {{end}}{{.CpCode}}{{if .CpINN}} · ИНН {{.CpINN}}{{end}}
    + + {{if .Secs}} +

    Ценные бумаги ({{len .Secs}})

    + + + + {{range .Secs}} + + + + + + + {{end}} + +
    ISIN / код НРДНаименованиеКол-во
    {{if .Bad}}🔴{{else}}🟢{{end}}{{.ISIN}}{{if ne .Code .ISIN}} · {{.Code}}{{end}}{{.Name}}{{if .Bad}} — неверная для этого кейса{{end}}{{.Qty}}
    + {{end}} + + {{if .WeInitiate}} + +
    +
    +

    Сверка списания с DEPO — эмуляция

    + {{if .Depo.Ran}}{{.Depo.Summary}}{{end}} +
    +

    Как Fancy-DEPO оценила бы это списание: клиент, торговый счёт, остаток, место хранения, блокировки, ИИС. Ничего не отправляется — только подсказка перед отправкой.

    + {{if not .Depo.Ran}} +

    Сверка не выполнена: {{.Depo.Err}}

    + {{else}} +
    Инвестор: {{if .Depo.Investor}}{{.Depo.Investor}}{{else}}не найден в картотеке{{end}} · Торговый счёт: {{if .Depo.Account}}{{.Depo.Account}}{{else}}нет открытого ВТНКЦ-счёта{{end}}
    + {{range .Depo.Secs}} +
    {{.Security}} → {{if eq .Recommend "confirm"}}списать можно{{else}}отказ {{.RejectCode}} ({{m2mCode .RejectCode}}){{end}}
    + + + {{range .Checks}} + + {{end}} + +
    {{if not .OK}}🔴{{else if .Warn}}🟡{{else}}🟢{{end}}{{.Name}}{{.Detail}}
    + {{end}} + {{end}} +
    + {{end}} + +
    + {{if .WeInitiate}} +
    + + +
    + Отмена + {{else}} +
    По этому кейсу мы Зачисляющий — данные не отправляем, а отвечаем на странице Входящие{{if .Case.Expect}} (код {{.Case.Expect}}){{end}}.
    + {{end}} +
    +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_m2mtest.html b/internal/lkgateway/web/templates/admin_m2mtest.html new file mode 100644 index 0000000..bdd8dc9 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_m2mtest.html @@ -0,0 +1,197 @@ +{{define "content"}} + + +
    +

    Тестирование M2M — с контрагентом (МБ / НРД)

    +
    + {{if .CounterpartyName}}{{.CounterpartyName}}{{else}}Контрагент 1{{end}} — списывающий (Контрагент 1)  →  Фонтвьель — зачисляющий (Контрагент 2, мы). +
    +

    По кейсам, где мы Списывающий, жмём «Инициировать отправку»; где Зачисляющий — отвечаем на странице Входящие (для негативных кейсов — с кодом отказа). По инициированным кейсам статус и исход НРД подтягиваются автоматически. Статусы сохраняются между перезапусками.

    + +
    + Всего: {{.Total}} + ✓ Успех: {{.Passed}} + ✗ Провал: {{.Failed}} + ● В процессе: {{.Prog}} + ○ Не начато: {{.Pending}} + {{if .Excluded}}⊘ Не тестируем: {{.Excluded}}{{end}} +
    + + {{if or .Passed .Prog .Failed}} +
    + ⬇ Отчёты по тестам + ⬇ Эталон для Fancy +
    + 🔄 Как работает процесс + 🧪 Имитация сверки с DEPO + 📖 Коды ошибок + Архивы для отправки контрагенту / программисту Fancy. Наведите на кнопку — подсказка, что внутри. +
    + {{end}} + +
    + +
    + {{if .CounterpartyCode}}Код: {{.CounterpartyCode}} · ИНН: {{if .CounterpartyINN}}{{.CounterpartyINN}}{{else}}—{{end}}{{else}}— выберите контрагента из списка —{{end}} +
    + + + + + +
    + +
    Наш депозитарий: {{if .OurName}}{{.OurName}}{{else}}Фонтвьель{{end}} · код {{.OurCode}}{{if .OurINN}} · ИНН {{.OurINN}}{{end}} (из справочника)
    +
    + + +
    +

    Данные для занесения в учётную систему

    +
    +
    +
    Тестовые клиенты
    + + + + +
    ФИОДУЛ
    К1Мостовиков Арсений Тимурович
    14.03.1999, г. Москва
    паспорт 4519 № 736284
    ГУ МВД России по г. Москве, 28.03.2019, 770-093
    К2Баранкин Сергей Сергеевичпаспорт 3763 № 290089 (кейсы M2M04)
    +
    +
    +
    Бумаги
    + + + + + + + +
    ISINКод НРДНаименование
    RU0009029540SBER/03Акции об. ПАО Сбербанк
    RU000A0JUR61RU000A0JUR61Пай ИПИФ «КОНСЕРВАТОРия»
    RU000A102QL3RU000A102QL3Облигация суборд. ВТБ (СУБ-Т1-2)
    RU0007661625RU0007661625Акции об. ПАО «Газпром» (M2M05)
    XS0088543193XS0088543193Минфин РФ (USD) (M2M01, не-RU)
    +
    +
    +
    + +
    + + + + + + + + {{$scen := ""}} + {{range .Rows}} + {{if ne .Scenario $scen}} + + {{$scen = .Scenario}} + {{end}} + + + + + + + + + + + + + {{end}} + +
    #СписывающийЗачисляющийНаша рольКлиентРасч.депоБумагиОжиданиеСтатусДействие
    Сценарий {{.Scenario}}
    {{.ID}} ⓘ{{.Debit}}{{.Credit}}{{.OurRole}}{{.Client}}{{.SettleDeb}}→{{.SettleCred}}{{.SecList}}{{if .Expect}}{{.Expect}}{{else}}успех{{end}}
    {{.Note}}
    + {{if .Excluded}} + ⊘ не тестируем + {{else}} +
    + + + + +
    + {{if .Live}}
    ⟳ {{.Live}}
    {{end}} + {{if .State.Note}}
    {{.State.Note}}
    {{end}} + {{if .State.GUID}}
    GUID: {{.State.GUID}}
    {{end}} + {{end}} +
    + {{if .Excluded}} + — + {{else if .WeInitiate}} + {{if .Secs}} + ▶ Инициировать + {{else}}Сценарий 2 — формируется вручную{{end}} + {{else}} + Ответить во «Входящих»{{if .Expect}}
    код {{.Expect}}{{end}}
    + {{end}} +
    +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_news.html b/internal/lkgateway/web/templates/admin_news.html index e3e287d..a049f5c 100644 --- a/internal/lkgateway/web/templates/admin_news.html +++ b/internal/lkgateway/web/templates/admin_news.html @@ -2,11 +2,11 @@ -{{if .Flash}}

    {{.Flash}}

    {{end}} +{{if .Flash}}

    {{.Flash}}

    {{end}}

    Новости и события

    События системы, окна техработ НРД, обновления документации и сертификатов. Лента не очищается — служит журналом для аудита. Скрытые новости можно посмотреть, сняв галочку «Только активные».

    - + {{if not .Settings.News.LastDocCheck.IsZero}} Последняя проверка: {{.Settings.News.LastDocCheck.Format "02.01.2006 15:04:05"}} {{end}} diff --git a/internal/lkgateway/web/templates/admin_npr.html b/internal/lkgateway/web/templates/admin_npr.html new file mode 100644 index 0000000..8a0888f --- /dev/null +++ b/internal/lkgateway/web/templates/admin_npr.html @@ -0,0 +1,32 @@ +{{define "content"}} +{{template "npr_css"}} + +

    📊 Риск-монитор: НПР и УДС клиентов

    НПР1 / НПР2 / УДС клиентов маржинальной торговли по данным экспорта QUIK. Модуль перенесён как есть, доработки — по мере необходимости.

    + +{{if eq .Tab "margin"}}

    Клиенты с открытыми маржинальными позициями (есть начальная или минимальная маржа). У кого УДС близок к 1 — под маржин-колл, у кого <0 — под принудительное закрытие.

    +{{else}}

    Клиенты без открытых позиций, но с отрицательной стоимостью портфеля (должны брокеру). УДС у таких счетов в QUIK уходит в −9,99 — это задолженность, а не маржин-колл.

    {{end}} +
    УДС — уровень достаточности средств (чем меньше, тем опаснее). НПР1 — запас относительно начальной маржи (отрицательный — запрещено наращивать позиции). НПР2 — запас относительно минимальной маржи (отрицательный — начинается принудительное закрытие). +
    маржин-колл (УДС<1) внимание (УДС<1,5) «∞» избыточно (УДС≥9,99)
    +
    +
    всего клиентов
    —
    +
    маржин-колл (УДС<1)
    —
    +
    внимание (УДС<1,5)
    —
    +
    норма
    —
    +
    избыточно (∞)
    —
    +
    +
    + Проверяю актуальность данных…
    + + + + + + + + +
    +

    Загружаю клиентов…

    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_npr_client.html b/internal/lkgateway/web/templates/admin_npr_client.html new file mode 100644 index 0000000..1e0497d --- /dev/null +++ b/internal/lkgateway/web/templates/admin_npr_client.html @@ -0,0 +1,47 @@ +{{define "content"}} +{{template "npr_css"}} +

    ← К списку клиентов{{if not .Custom}}⟳ Страница обновится автоматически через 60 сек{{end}}

    +{{if not .Custom}}{{end}} +
    +

    Клиент {{.C.ClientCode}}

    Тип: {{if .C.MarginType}}{{.C.MarginType}}{{else}}—{{end}} · Данные на {{.C.TS.Local.Format "02.01.2006 15:04:05"}}
    +
    +
    Уровень достаточности средств (УДС)
    {{if .C.UDS}}{{nprF .C.UDS}}{{else if eq .C.UDSFlag "INF"}}∞{{else if eq .C.UDSFlag "CALL"}}маржин-колл{{else}}—{{end}}{{if .C.BadgeClass}}
    {{.C.BadgeText}}
    {{end}}
    +
    НПР1 (запас до начальной маржи)
    {{nprF .C.RCV1}}
    +
    НПР2 (запас до минимальной маржи)
    {{nprF .C.RCV2}}
    +
    Стоимость портфеля
    {{nprF .C.PortfolioValue}}
    +
    Маржинальное требование
    {{nprF .C.MarginReq}}
    +
    +
    +
    Начальная маржа
    {{nprF .C.InitMargin}}
    +
    Минимальная маржа
    {{nprF .C.MinMargin}}
    +
    Скорректированная маржа
    {{nprF .C.CorrMargin}}
    +
    +
    +
    +

    История изменений

    +
    {{range .Periods}}{{.T}}{{end}}или свой диапазон (МСК):
    +
    + + + Сбросить + ⤓ Скачать CSV (для регулятора) +
    +

    Показано: с {{.TF.Local.Format "02.01.2006 15:04"}} по {{.TT.Local.Format "02.01.2006 15:04"}} (МСК).

    + +

    Последние {{len .History}} записей

    + +{{range .History}}{{end}}
    ВремяУДССтатусНПР1НПР2Марж. требование
    {{.TS.Local.Format "02.01.2006 15:04:05"}}{{nprF .UDS}}{{if .BadgeClass}}{{.BadgeText}}{{else}}—{{end}}{{nprF .RCV1}}{{nprF .RCV2}}{{nprF .MarginReq}}
    +
    + + +{{end}} diff --git a/internal/lkgateway/web/templates/admin_npr_rows.html b/internal/lkgateway/web/templates/admin_npr_rows.html new file mode 100644 index 0000000..df7ffed --- /dev/null +++ b/internal/lkgateway/web/templates/admin_npr_rows.html @@ -0,0 +1,7 @@ +{{define "npr_rows"}} +

    Найдено клиентов: {{.Total}}.{{if .Pages}} Страница {{.Page}} из {{.Pages}}.{{end}}

    + +{{range .Rows}}{{end}} +{{if not .Rows}}{{end}}
    Код клиентаТипУДССтатусНПР1НПР2Марж. требованиеОбновлено
    {{.ClientCode}}{{if .MarginType}}{{.MarginType}}{{else}}—{{end}}{{nprF .UDS}}{{if .BadgeClass}}{{.BadgeText}}{{else}}—{{end}}{{nprF .RCV1}}{{nprF .RCV2}}{{nprF .MarginReq}}{{.TS.Local.Format "15:04:05"}}
    Ни одного клиента под этот фильтр не найдено
    + +{{end}} diff --git a/internal/lkgateway/web/templates/admin_quik_instructions.html b/internal/lkgateway/web/templates/admin_quik_instructions.html new file mode 100644 index 0000000..3c22df1 --- /dev/null +++ b/internal/lkgateway/web/templates/admin_quik_instructions.html @@ -0,0 +1,99 @@ +{{define "content"}} +{{if .Flash}}
    {{.Flash}}
    {{end}} +
    + +

    Цикл M2M: поручения, которые ведёт bj

    +

    Записи в МНП: {{if .Cycle.WritesEnabled}}включены{{else}}выключены{{end}} · исходящие: {{if .Cycle.AutoOut}}авто{{else}}вручную{{end}} · входящие: {{if .Cycle.AutoIn}}авто{{else}}вручную{{end}} · поручения из терминала: {{if .Cycle.PollTerminal}}подхватываются{{else}}не отслеживаются{{end}} — настроить.

    + {{if not .Links}}

    Пока пусто.

    {{else}} + + + {{range .Links}} + + + + + + + + + {{end}} +
    КогдаПереводНаправлениеКлиентБумагаПоручение МНПСостояниеПоследний шаг
    {{msk .UpdatedAt}}{{if .IsTerminal}}из терминала{{else}}{{short 8 .GUID}}{{end}}{{if eq .Direction "out"}}↗ вывод{{else}}↘ ввод{{end}}{{.ClientID}}{{if .Contract}} {{.Contract}}{{end}}{{.ISIN}} × {{.Qty}}{{if .InstrNo}}№{{.InstrNo}}{{else}}—{{end}}{{.StateRu}}{{if .Err}}
    {{.Err}}
    {{end}}
    {{with .LastStep}}{{.Action}}: {{.Reply}}{{end}}
    {{end}} +
    + +{{if .Cycle.PollTerminal}} +
    +

    Поручения QUIK (M2M)

    + Неторговые поручения на вывод/ввод ЦБ из Модуля неторговых поручений QUIK. Бумаги по ним уже заблокированы на позиции клиента. Режим — только чтение{{if .DBName}} (БД {{.DBName}}){{end}}. +
    + +
    +

    Как это работает

    +

    + Клиент подаёт «Вывод ЦБ (M2M)» из терминала QUIK / мобильного / ЛК → сервер QUIK проверяет остаток и блокирует бумаги → поручение попадает в экспортную таблицу МНП → bj читает его отсюда, проверяет по депозитарию и отправляет перевод в НРД (MOEX МОСТ) → по ответу НРД поручение подписывается (списание) либо снимается (разблокировка). +
    Сейчас реализовано чтение; запись (подпись/снятие/регистрация) — следующий этап.

    +
    + +
    +

    + {{if .OnlyActive}}Активные поручения{{else}}Все поручения M2M{{end}} ({{len .Items}}) + + {{if .OnlyActive}}показать все{{else}}только активные{{end}} + · настройки + +

    + {{if .Error}}

    {{.Error}}

    {{end}} + {{if .Items}} +
    + + + + + + + {{range .Items}} + + + + + + + + + + + + {{end}} + +
    № / IDЗарегистрированоКлиентДоговорБумага (ISIN)Кол-воЗаблок.Депозитарий получателя (ИНН)Статус
    {{.Number}}
    id {{.InstrID}}{{if .ExtID}} · ext {{.ExtID}}{{end}}
    {{.Registered}}{{.ClientID}}{{.AgreeServ}}{{if .ISIN}}{{.ISIN}}{{end}}{{if .SecCode}}
    {{.SecCode}} / {{.ClassCode}}{{end}}
    {{.Volume}}{{if .QtyBlocked}}{{.QtyBlocked}}{{else}}—{{end}}{{if .RecipINN}}{{.RecipINN}}{{else}}—{{end}} + {{if .PassExpense}}
    + расходы{{end}} + {{if .CloseIIA}}
    закрытие ИИС{{end}}
    + {{if eq .Status 0}}{{.StatusName}} + {{else if eq .Status 1}}{{.StatusName}} + {{else}}{{.StatusName}}{{end}} + {{if .Deny}}
    {{.Deny}}{{end}} +
    +
    + {{else if not .Error}} +

    Поручений M2M пока нет{{if .OnlyActive}} в активном статусе — посмотреть все{{end}}. Они появятся, когда клиент подаст «Вывод ЦБ (M2M)» в QUIK.

    + {{end}} +
    +{{else}} +
    +

    Поручения из терминала QUIK — не отслеживаются

    +

    Опция «Принимаем поручения M2M из QUIK» выключена: клиентам и фирме не разрешена подача «Вывод ЦБ (M2M)» из терминала / мобильного приложения, поэтому bj их не опрашивает и не показывает. Все переводы ведутся через заявки bj (таблица выше). +
    Если брокер откроет клиентам эту возможность (классы INSTROUTM2M/INSTRINM2M в QuikAdmin), включите опцию в Настройка → QUIK МНП → Цикл M2M — bj начнёт подхватывать такие поручения и создавать по ним заявки оператору. +
    В перспективе: при подключении модуля управления правами клиентов (QuikAdmin) опция будет назначаться клиенту автоматически вместе с нужными классами.

    +
    +{{end}} +{{end}} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index cf24bd5..af2a722 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -1,16 +1,24 @@ {{define "content"}} -{{if .Flash}}
    {{.Flash}}
    {{end}} +{{if .Flash}}
    {{.Flash}}
    {{end}}
    {{/* ===== Боковая навигация разделов с индикаторами ===== */}}
    @@ -42,7 +50,7 @@

    Подключить автоматически

    Поднимем локальный PostgreSQL в контейнере, применим миграции и запишем DSN. Для дев-стенда. Для прода — укажите свой DSN ниже.

    - +
    {{end}} @@ -58,12 +66,439 @@
    + {{if .AdvancedUnlocked}} + {{/* ============ ОБМЕН FANCY (MSSQL) — перспективный модуль ============ */}} +
    +

    Обмен Fancy (MSSQL / PostgreSQL)

    +
    +

    Подключение к серверу БД клиента

    +

    Сервер учётной базы Fancy DEPO: картотека лиц, счета и договоры, остатки, опции (признак квалифицированного инвестора), трансферные таблицы обмена. Поддерживаются Microsoft SQL Server и PostgreSQL. База указывается ниже, в карточке «Трансферные таблицы DEPO».

    +
    + + + + + + +
    +
    +
    + +
    +

    Трансферные таблицы DEPO

    +

    Через эти таблицы bj обменивается с учётной системой DEPO: поручения FTV_M2M и их строки FTV_M2M_LINES, события FTV_M2M_EVENTS, справочник участников НРД FTV_M2M_HANDBOOK. Таблицы создаются в базе DEPO на сервере из подключения выше ({{if .DepoTables.Postgres}}PostgreSQL{{else}}Microsoft SQL Server{{end}}). Создание можно повторять: добавляются только недостающие таблицы, колонки и индексы, данные не меняются.

    +
    + + + + + +
    +
    + + {{if eq .Settings.Fancy.DepoTablesMode "auto"}} +
    + +
    Логин с правами на базу {{.DepoTables.DB}}
    + {{if .DepoTables.Postgres}}Владелец базы или суперпользователь.{{else}}Роль db_owner в базе (или db_ddladmin + db_securityadmin).{{end}} Если оставить поля пустыми, bj попробует своим логином {{.DepoTables.User}}.
    +
    + + +
    +
    +
    + {{else if eq .Settings.Fancy.DepoTablesMode "script"}} +
    Скрипт для администратора СУБД — скачать {{.DepoTables.ScriptFile}} +
    {{.DepoTables.Script}}
    + {{end}} + +
    +
    + логином bj: таблицы на месте, колонки совпадают, есть права на чтение и запись +
    + {{if .Settings.Fancy.DepoTablesCheck}}

    {{if .Settings.Fancy.DepoTablesCheckOK}}✓{{else}}✗{{end}} {{.Settings.Fancy.DepoTablesCheck}} ({{.Settings.Fancy.DepoTablesCheckAt.Format "02.01.2006 15:04"}})

    {{end}} +
    + +
    +

    Где запускается обработка трансферных таблиц DEPO

    +

    Поручения и события, которые bj пишет в FTV_M2M*, обрабатывает процедура {{.DepoJob.Proc}} в базе DEPO. Выберите, кто её запускает, выполните инструкцию и нажмите «Проверить готовность» — без зелёной отметки обмен с DEPO работать не будет.

    +
    + + + + +
    + + + +
    +

    Процедура обработки выполняется от имени пользователя базы DEPO (у разработчика Fancy это SYSDBA). У клиента такого пользователя может не быть или он называется иначе: «Проверить готовность» проверит, что он есть в базе.

    +
    +
    + + {{if eq .Settings.Fancy.DepoJobMode "bj"}} +
    Инструкция для администратора SQL клиента — выполнить один раз: +
    {{.DepoJob.GrantScript}}
    + {{else if eq .Settings.Fancy.DepoJobMode "sql-auto"}} +
    Инструкция для администратора SQL клиента — выдать логину bj права оператора SQL Agent (SQL Agent должен быть запущен): +
    {{.DepoJob.AgentRights}}
    +
    + {{else if eq .Settings.Fancy.DepoJobMode "sql-manual"}} +
    Инструкция для администратора SQL клиента — создать джобу (SQL Agent должен быть запущен, служебная учётная запись Agent должна иметь право выполнять процедуру): +
    {{.DepoJob.AgentScript}}
    + {{end}} + + {{if .Settings.Fancy.DepoJobMode}} +
    +
    + для вариантов с джобой bj пишет служебное событие BJ_PING и ждёт до 40 с, пока обработчик его отметит +
    + {{if .Settings.Fancy.DepoJobCheck}}

    {{if .Settings.Fancy.DepoJobCheckOK}}✓{{else}}✗{{end}} {{.Settings.Fancy.DepoJobCheck}} ({{.Settings.Fancy.DepoJobCheckAt.Format "02.01.2006 15:04"}})

    {{end}} + {{if and (eq .Settings.Fancy.DepoJobMode "bj") .Settings.Fancy.DepoJobLast}}

    Последний вызов процедуры — {{.Settings.Fancy.DepoJobLast}}

    {{end}} + {{end}} +
    + +
    + + {{/* ============ QUIK МНП ============ */}} +
    +

    QUIK — Модуль неторговых поручений

    +
    +

    Подключение к БД МНП QUIK (MSSQL)

    +

    При выводе бумаг клиентом bj блокирует их количество в QUIK через API Модуля неторговых поручений (хранимые процедуры). БД боевая — тест только на чтение (версия + наличие M2M-объектов), без записи. Подключение под логином с ролью InstrEu.
    + Инструкция для администратора QUIK — что настроить в QuikAdmin для M2M · документы ARQA.

    +
    + + + + + + + +
    + +
    +
    +
    +
    +
    +

    Статус: {{if .Settings.Quik.Verified}}✓ подключение проверено{{else}}не проверено{{end}}

    +
    + +
    +

    Автоматический цикл M2M в МНП

    +

    Что делает bj в Модуле неторговых поручений по ходу перевода: исходящий — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; входящий — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице QUIK МНП.

    +
    + + + + + + +
    + + +
    + +
    +
    +
    + + {{if .Settings.Quik.LastReport}} +
    +

    Полный ответ сервера (последний тест)

    +

    Можно выделить и скопировать целиком — для отправки коллегам/аутсорсеру QUIK. + {{if not .Settings.Quik.LastAt.IsZero}}Тест от {{.Settings.Quik.LastAt.Format "02.01.2006 15:04:05"}} UTC.{{end}}

    +
    + +
    +
    {{.Settings.Quik.LastReport}}
    +
    + {{end}} +
    + + {{end}}{{/* /AdvancedUnlocked — конец перспективных модулей Fancy/QUIK */}} + + {{/* ============ ПОЛЬЗОВАТЕЛИ И ВХОД ============ */}} +
    + {{$U := .Users}}{{$A := .Settings.Auth}} +

    Пользователи и вход

    +

    Кто может входить в bj и что ему разрешено. Вход — локальной учётной записью или доменной (Active Directory), роль доменного пользователя назначается по его группам AD.

    + {{if $U.Open}}
    Вход пока не требуется: пользователи не созданы. Создайте первого администратора — после этого все страницы потребуют входа.
    {{end}} + +
    +

    Роли

    + + {{range $U.Roles}}{{end}} + {{range $U.Hidden}}{{end}} +
    РольЧто разрешено
    {{.Title}}
    {{.Key}}
    {{.Desc}}
    {{.Title}}Недоступна: в лицензии нет модуля «{{.Missing}}».
    +

    {{if $U.Modules}}Подключённые модули ({{range $i, $m := $U.Modules}}{{if $i}}, {{end}}«{{$m.Title}}»{{end}}) можно выдать любому пользователю дополнительно к роли. {{end}}Роли и модули показываются по лицензии: права модуля, которого нет в лицензии, не действуют. Настройки и пользователи — только администратор.

    +
    + +
    +

    Пользователи ({{len $U.Users}})

    + {{range $U.Users}} +
    + +
    {{.Login}} {{if eq .Source "ad"}}AD{{end}}{{if not .Active}}заблокирован{{end}}
    +
    {{if not .LastLogin.IsZero}}вход {{msk .LastLogin}}{{else}}ещё не входил{{end}}{{if .Note}} · {{.Note}}{{end}}
    + {{if .ADGroups}}
    группы: {{range $i,$g := .ADGroups}}{{if $i}}, {{end}}{{$g}}{{end}}
    {{end}}
    + + + +
    +
    + {{else}}

    Пользователей нет.

    {{end}} + +

    {{if $U.Open}}Создать первого администратора{{else}}Добавить пользователя{{end}}

    +
    + + + + + + + {{if $U.Modules}}{{end}} +
    +
    +

    Доменного пользователя можно заранее завести с источником «Active Directory», чтобы выдать ему модули; роль при входе возьмётся из групп AD. Если потерян доступ администратора — удалите файл /var/lib/bj/.bj/users.json на сервере: система вернётся в режим первичной настройки.

    +
    + +
    +

    Вход через Active Directory

    +

    bj подключается к контроллеру домена и проверяет пароль пользователя (LDAP bind его же учётной записью). Роль назначается по членству в группах AD, включая вложенные. Пользователь появляется в списке при первом входе. Чтобы изменения групп действовали без повторного входа, укажите сервисную учётную запись только на чтение: bj будет перечитывать группы доменных пользователей по расписанию, менять роль и снимать доступ у удалённых из групп.

    +
    + +
    + + +
    + +
    + + +
    + +
    + + + +
    +
    +
    Группы AD → роли (имя группы или полный DN; первое совпадение сверху)
    +
    + {{range $A.ADGroups}}
    {{end}} + {{range $i := list "1" "2"}}
    {{end}} +
    +
    +
    + + +
    +
    +
    +
    + + + +
    +

    Без логина и пароля проверяется только доступность контроллера. С логином — вход, группы и вычисленная роль. Пароль проверки никуда не сохраняется.

    + {{$R := $U.ADRecheck}} +
    + {{if and $A.ADEnabled $A.ADBindUser $A.ADBindPassword}} + Перепроверка групп каждые {{if $A.ADRecheckMinutes}}{{$A.ADRecheckMinutes}}{{else}}15{{end}} мин: {{if $R.At.IsZero}}ещё не выполнялась{{else}}последняя {{msk $R.At}}, проверено {{$R.Checked}}, изменений {{$R.Changed}}{{if $R.Err}} — ошибка: {{$R.Err}}{{end}}{{end}} +
    + {{else}}Перепроверка групп выключена: роль доменного пользователя меняется только при следующем входе. Укажите сервисную учётную запись выше.{{end}} +
    +
    + +
    +

    Активные сессии ({{len $U.Sessions}})

    + {{if $U.Sessions}} + {{range $U.Sessions}} + {{end}} +
    ПользовательВходАктивностьАдресБраузер
    {{.Login}}{{msk .Created}}{{msk .Seen}}{{.IP}}{{.UA}}
    {{else}}

    Сессий нет.

    {{end}} +
    +
    + + {{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}} +
    + {{$X := .QuikExport}}{{$S := $X.S}} +

    QUIK — Экспорт биржевой информации

    +

    Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак quik-export.

    + {{range $X.Warnings}}
    {{.}}
    {{end}} + +
    +

    Состояние модуля

    +
    + + + + {{if not $X.Licensed}}не входит в лицензию — см. раздел «Лицензия»{{end}} +
    +
    +
    Служба quikexport
    + {{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}} +
    {{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}
    +
    Данные из QUIK
    + {{if $X.DB.OK}}{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}} +
    {{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}
    + {{else}}база недоступна
    {{$X.DB.Error}}
    {{end}}
    +
    Объём базы {{$S.DBName}}
    + {{printf "%.0f" $X.DB.DBSizeMB}} МБ +
    история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)
    +
    Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}
    + {{with $X.DiskDB}}{{if .OK}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
    занято {{.UsedPct}}% · {{.Path}}
    {{end}}{{end}} + {{if not $X.DiskDB.OK}}{{with $X.DiskExp}}свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}
    занято {{.UsedPct}}%
    {{end}}{{end}}
    +
    Логи и кэш экспортёра
    + {{printf "%.0f" $X.LogsMB}} МБ логов +
    {{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий
    +
    + {{if $X.DB.Tables}}
    Крупнейшие таблицы базы + {{range $X.DB.Tables}}{{end}}
    ТаблицаРазмерСтрок (оценка)
    {{.Name}}{{.Size}}{{.Rows}}
    {{end}} +
    +
    +
    + {{if and $X.Service.Installed (not $X.Service.Enabled)}}
    {{end}} +
    + {{if $X.LogTail}}
    Последние строки export.log{{if $X.ErrTail}} и export.err{{end}} +
    {{range $X.LogTail}}{{.}}
    +{{end}}{{if $X.ErrTail}}--- export.err ---
    +{{range $X.ErrTail}}{{.}}
    +{{end}}{{end}}
    {{end}} +
    + +
    +

    1. Подключение к серверу QUIK

    +

    Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей pubring.txk / secring.txk к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».

    +
    + + + + + + +
    +
    +
    + + + + +
    +

    Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}{{.}} {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}. + В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}

    +
    + +
    +

    2. База данных экспорта

    +

    Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).

    +
    + +
    + + +
    +
    + + +
    +
    + + + + + +
    +
    + + SQL для внешнего сервера
    +
    +

    Доступ для внешних систем и других модулей: {{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}} + {{if $X.ShowRO}}скрыть пароль{{else}}показать пароль{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост {{$X.ServerIP}}, доступ по IP открывает администратор (pg_hba){{end}}.

    +
    + +
    +

    3. Объём данных

    +

    Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме npr.

    +
    + +
    {{range $X.Presets}} + {{end}} +
    +
    + {{range $g := $X.Groups}}
    {{$g}}
    +
    {{range $X.Tables}}{{if eq .Group $g}} + {{end}}{{end}}
    {{end}} +
    +
    Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}{{.}} {{end}}
    +
    + + + + +
    +
    +
    +
    + +
    +

    4. Применить к экспортёру

    +

    bj собирает export.ini, info.ini, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы ({{$S.Dir}}) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}

    +
    + Предпросмотр файлов +
    +
    +
    +

    Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в {{$S.Dir}}; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.

    +
    +
    + {{/* ============ КРИПТОГРАФИЯ ============ */}}

    Криптография

    -
    -

    🔑 Установка ключа на флешку

    +
    +

    Установка ключа на флешку

    Пошаговый мастер: загрузить архив НРД с паролем → запись на флешку → справочник сертификатов → проверка Валидаты → готово.

    Открыть мастер установки ключа →
    @@ -94,85 +529,44 @@
    - {{/* Носители ключей */}} -
    -

    Носители ключей

    -

    USB-флешки сканируются автоматически. Образы (.iso/.img/.zip/.7z) загружаются ниже — bj-server распакует и найдёт профиль Валидаты, контейнеры, сертификаты.

    -
    - - - -
    -

    Лимит 500 МБ. Распаковка через 7z.

    - - {{if .Media}} - {{range .Media}} -
    -
    -
    {{if eq .Kind "iso"}}📀 ISO{{else}}🔌 USB{{end}} {{.Mountpoint}}{{if .Source}}{{.Source}}{{end}}
    - {{if eq .Kind "iso"}} -
    - -
    - {{end}} -
    - {{if .Profile}} -

    Профиль Валидаты

    - - - - - - -
    ПСП (.pse){{range .Profile.PSEFiles}}{{.}}
    {{end}}
    ЛСП (.gdbm){{range .Profile.GDBMFiles}}{{.}}
    {{end}}
    Ключи (.vdk){{range .Profile.KeyFiles}}{{.}}
    {{end}}
    -
    - - - {{if .Profile.Imported}}✓ импортирован{{else}}{{end}} -
    - {{end}} - {{if .Containers}} -

    Контейнеры ({{len .Containers}})

    - - - {{range .Containers}} - - - {{end}} -
    ИмяСтатус
    {{.Name}} {{.Path}}{{if .Imported}}импортирован{{else}}нет{{end}}{{if not .Imported}}
    {{end}}
    - {{end}} - {{if and (not .Containers) (not .Certificates) (not .Profile)}}

    Профиль Валидаты не найден на носителе.

    {{end}} -
    - {{end}} - {{else}}

    Носители не обнаружены. Подключите USB или загрузите образ.

    {{end}} - - {{if .ImportedProfiles}} -

    Импортированные профили

    - - - {{range .ImportedProfiles}} - - - {{end}} + {{/* Доверенные сертификаты НРД в справочнике ИШ */}} +
    +

    Сертификаты НРД в справочнике ИШ

    +

    Комплект доверенных сертификатов крипто-сервера НРД поставляется вместе с bj и при обновлении приезжает всем клиентам. bj сам сверяет справочник активного профиля ИШ{{if .NSDCerts.Profile}} ({{.NSDCerts.Profile}}){{end}}: недостающие добавляет (только если их УЦ уже есть в справочнике — боевой сертификат не попадёт в тестовый профиль), старые удаляет строго после разрешённого НРД срока. Каждое изменение — с бэкапом, проверкой подписи и откатом при ошибке; ИШ перезапускается на несколько секунд.

    +
    ИмяСостояниеДействия
    {{.}}{{if eq . $.Settings.Crypto.Profile}}✓ активен{{else}}не активен{{end}} - {{if ne . $.Settings.Crypto.Profile}}
    {{end}} -
    -
    + + {{range .NSDCerts.Rows}} + + + + + {{end}}
    СертификатКонтурДействуетСтатус
    {{.Title}}
    {{.SubjectCN}} · выдан: {{.IssuerCN}} · серийный {{.Serial}}
    {{.Contour}}{{.ValidFrom}} — {{.ValidTo}}{{if eq .Kind "ok"}}✓ {{.Status}}{{else if eq .Kind "add"}}⬆ {{.Status}}{{else}}{{.Status}}{{end}}{{if .RemoveNote}}
    {{.RemoveNote}}
    {{end}}
    - {{end}} +
    +
    +
    +
    + {{if not .NSDCerts.LastRun.IsZero}}сверено {{.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{end}} +
    + {{if .NSDCerts.LastLog}}
    {{.NSDCerts.LastLog}}
    {{end}} + {{if .NSDCerts.Store}}
    Содержимое справочника профиля ({{len .NSDCerts.Store}} записей) + {{range .NSDCerts.Store}}{{end}}
    ВладелецИздательСерийныйДоХранилище
    {{if eq .Kind "crl"}}CRL{{else}}{{.SubjectCN}}{{if .SelfSigned}} (УЦ){{end}}{{end}}{{.IssuerCN}}{{.Serial}}{{.NotAfter}}{{.Store}}
    {{end}} +

    Свои сертификаты можно положить в /var/lib/bj/.bj/nsd-certs/*.cer — они добавляются по тем же правилам и никогда не удаляются автоматически.

    {{/* Сертификаты УЦ */}} -
    +

    Сертификаты УЦ (авто-загрузка)

    URL .cer-файлов УЦ НРД (nsd.ru). Скачиваются, парсятся и сохраняются в /var/lib/bj/ca-certs/.

    +
    -
    {{if not .Settings.CACerts.LastFetch.IsZero}}обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}{{end}}
    +
    {{if not .Settings.CACerts.LastFetch.IsZero}}обновлено {{.Settings.CACerts.LastFetch.Format "02.01.2006 15:04"}}{{end}}
    {{if .Settings.CACerts.FetchedCerts}} @@ -211,6 +605,11 @@ +
    + + + {{/* ============ ЛИЧНЫЙ КАБИНЕТ ============ */}} +
    +

    Личный кабинет · ЕСИА Финанс

    +

    Интеграция с платформой ЛК ЕСИА Финанс: адрес для клиента, приём заявок M2M, возврат статуса и подключение к API (тест/бой). НРД настраивается отдельно — в разделе «НРД».

    -

    Callback в личный кабинет (необязательно)

    -

    {{if .Settings.LK.CallbackURL}}{{.Settings.LK.CallbackURL}}{{else}}Не настроен — уведомления в ЛК отключены. Для работы с НРД не требуется.{{end}}

    -
    - - +

    Личный кабинет — интеграция

    +

    Адрес ЛК (стенд/бой), куда клиент подаёт заявку M2M. Заявка приходит к нам по контракту POST /api/v1/back_office/claims/, а мы возвращаем статус через Callback URL.

    + +
    + +
    + + {{if .Settings.LK.PublicURL}}Открыть →{{end}} +
    + Показывается ссылкой на дашборде для первичного теста. +
    +
    + + + {{if not .Settings.LK.CallbackURL}}Не настроен — уведомления в ЛК отключены. Для обмена с НРД не требуется.{{end}} +
    + +
    +

    Подключение к API ЛК — тест и бой

    +

    Реквизиты, по которым bj обращается к API платформы ЛК ЕСИА Финанс (HTTP Basic). Заполните нужные окружения и нажмите «Проверить связь» — сразу видно, есть подключение или нет.

    + +
    + {{/* --- Тест --- */}} +
    +
    + Тестовый стенд + {{if .Settings.LK.Test.LastMsg}}{{if .Settings.LK.Test.LastOK}}связь есть{{else}}нет связи{{end}}{{end}} + {{if eq .Settings.LK.ActiveEnv "test"}}активно{{end}} +
    +
    + +
    +
    +
    +
    + + +
    + + {{if .Settings.LK.Test.LastMsg}}

    Последняя проверка: {{.Settings.LK.Test.LastMsg}} · {{.Settings.LK.Test.LastAt.Format "02.01 15:04"}}

    {{end}} +
    + + {{/* --- Бой --- */}} +
    +
    + Боевой контур + {{if .Settings.LK.Prod.LastMsg}}{{if .Settings.LK.Prod.LastOK}}связь есть{{else}}нет связи{{end}}{{end}} + {{if eq .Settings.LK.ActiveEnv "prod"}}активно{{end}} +
    +
    + +
    +
    +
    +
    + + +
    + + {{if .Settings.LK.Prod.LastMsg}}

    Последняя проверка: {{.Settings.LK.Prod.LastMsg}} · {{.Settings.LK.Prod.LastAt.Format "02.01 15:04"}}

    {{end}} +
    +
    + +
    + + + + bj работает с ЛК через выбранное окружение. + + + +
    + +
    +

    Симулятор заявки M2M

    +

    Пока подача через веб-форму у ЕСИА Финанс не готова — bj сам собирает заявку depo_m2m и отправляет её в API ЛК (POST /claims/) через активное окружение. Ответ показывается ниже; затем заявку ловим опросом в «Переводы». Поля предзаполнены рабочим тестовым договором.

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +

    Результат (сырой ответ API) появится вверху страницы. Если «Раздел счёта не доступен для быстрых переводов» — Финансы должны включить признак M2M на разделе счёта.

    +
    + +
    +

    Забрать заявки из ЛК

    +

    Коннектор bj вычитывает заявки depo_m2m активного окружения в статусах «В очереди» и «В работе», которые ещё не обрабатывал, для каждой собирает M2M-пакет, отправляет в НРД и переводит заявку в статус «В работе». По ответу НРД статус в ЛК замыкается автоматически (Исполнена / Отклонена). В режиме имитации НРД перед отправкой идёт сверка с DEPO: отказ сразу уходит в ЛК с кодом и причиной, одобренная заявка блокируется в QUIK и через 30 с получает имитированный ответ контрагента. Штатно это делает фоновый поллер раз в минуту — кнопка для ручного запуска.

    +
    +

    Итог (сколько получено / отправлено, ошибки) появится вверху страницы.

    +
    {{/* ============ ТЕСТЫ ============ */}}

    Тесты

    +
    +

    Здоровье сервиса

    +

    Фоновые компоненты под самоконтролем: при сбое перезапускаются автоматически, при зависании — перезапуск всего сервиса и запись в «Новости». Ниже — живой статус.

    + {{if .Health}} +
    +
    ВладелецТипДоСтатус
    + + + {{range .Health}} + + + + + + + {{end}} + +
    КомпонентСостояниеПоследняя отметкаПоследняя ошибка
    {{healthLabel .Name}}{{if .Healthy}}работает{{else}}не отвечает{{end}}{{dur .Age}} назад{{if .LastErr}}{{.LastErr}}{{else}}—{{end}}
    +
    + {{else}} +

    Компоненты ещё не зарегистрированы (сервис только запущен).

    + {{end}} +
    + +
    +

    Справочник участников M2M

    +

    Запрос актуального справочника участников сервиса (пакет #M2MHR в НРД). В ответ НРД присылает #M2MTH — карту ИНН → MC-код депозитариев/брокеров и перечень мест расчётов. Нужно для сопоставления ИНН контрагента с кодом участника (блокеры вида M2M14/M2M15) и понимания, кто вообще зарегистрирован.

    +
    +

    Ответ придёт во входящие ИШ (~30-60с). Список появится в логе bj-server и в m2m-handbook.json.

    +
    +

    Тестовый пакет роботу НРД

    Робот MC0012500000 эмулирует вторую сторону перевода. Выберите сценарий — bj-server отправит эталонный запрос через ИШ, ответ придёт во входящие. Требуется настроенный ИШ + профиль Валидаты.

    @@ -282,6 +833,90 @@
    + {{/* ============ ОПОВЕЩЕНИЯ ============ */}} +
    +

    Оповещения

    +

    Системные события (сбой или зависание фонового компонента, аварийный перезапуск) всегда попадают в ленту «Новости». Дополнительно их можно доставлять наружу — на почту оператору и/или в мониторинг Zabbix, чтобы узнать о проблеме, даже когда интерфейс bj никто не открыл.

    + +
    +

    Почта (SMTP)

    +

    Отправка уведомлений по e-mail. Пароль хранится в конфигурации сервиса.

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + {{if .Settings.Alerts.SMTP.LastMsg}}

    Последняя проверка: {{.Settings.Alerts.SMTP.LastMsg}}

    {{end}} +
    + +
    +

    Zabbix (хук мониторинга)

    +

    bj шлёт значение на элемент типа Zabbix trapper (протокол zabbix_sender). В Zabbix заведите host и item-trapper с этим ключом, затем триггер по значению — уведомления настроит сам Zabbix.

    +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + {{if .Settings.Alerts.Zabbix.LastMsg}}

    Последняя проверка: {{.Settings.Alerts.Zabbix.LastMsg}}

    {{end}} +
    + +
    +

    Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис

    +

    Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.

    +
    + +
    +
    +
    +
    +
    + + {{range .NotifyGroups}}{{end}} + {{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}{{range $g := $.NotifyGroups}}{{end}}{{end}} +
    Тип уведомления{{.Title}}
    {{$k.Title}}
    +
    + + +
    +
    +

    Требуется настроенный и включённый SMTP в разделе «Почта».

    +
    + +
    + {{/* ============ ОБНОВЛЕНИЯ ============ */}}

    Обновления

    @@ -302,7 +937,7 @@
    {{if .Settings.Update.Notes}}

    Что нового: {{.Settings.Update.Notes}}

    {{end}} {{if not .Settings.Update.LastCheck.IsZero}}

    Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}

    {{end}} - {{if and .License.Present (not .License.AllowsUpdates)}}

    ⚠ Текущий план «{{.License.Plan}}» не включает обновления.

    {{end}} + {{if and .License.Present (not .License.AllowsUpdates)}}

    Текущий план «{{.License.Plan}}» не включает обновления.

    {{end}}
    @@ -326,6 +961,16 @@ {{/* ============ ЛИЦЕНЗИЯ ============ */}}
    +
    +

    Модуль «НПР / УДС» (риск-монитор по данным QUIK)

    +

    Показывает НПР1 / НПР2 / УДС клиентов маржинальной торговли по базе экспорта QUIK (схема npr). Отдельно лицензируемый модуль (признак npr); при включении в меню появляется пункт «НПР / УДС». Подключение — логин только для чтения.

    +
    + + + +
    +
    +

    Лицензия

    @@ -338,8 +983,11 @@ {{if .License.Tenant}}Организация{{.License.Tenant}}{{end}} {{if .License.Plan}}План{{.License.Plan}}{{end}} - {{if not .License.ExpiresAt.IsZero}}Действует до{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}(осталось {{.License.DaysLeft}} дн.){{end}}{{end}} + Тип ключа{{if .License.Online}}онлайн: выдан сервером лицензий этой установке, продлевается автоматически{{else}}офлайн (закрытый контур){{end}} + {{if not .License.ExpiresAt.IsZero}}Ключ действует до{{.License.ExpiresAt.Format "02.01.2006"}} {{if .License.Valid}}(осталось {{.License.DaysLeft}} дн.){{end}}{{end}} + {{if not .License.ContractUntil.IsZero}}Договор до{{.License.ContractUntil.Format "02.01.2006"}}{{end}} Обновления{{if .License.AllowsUpdates}}включены{{else}}не входят в план{{end}} + Модули{{range .Features}}
    {{.Title}} {{.Key}}{{if not .On}}— не входит в лицензию{{end}}
    {{end}} Статус{{.License.Message}} @@ -349,18 +997,90 @@

    -

    Активация

    -

    Вставьте лицензионный ключ, полученный от поставщика. Проверка офлайн по подписи — связь с сервером лицензий не требуется.

    -
    - - -
    - Публичный ключ издателя (если не зашит) - -
    -
    +

    Активация на сервере лицензий

    +

    Введите код активации от поставщика (вида BJ-XXXX-XXXX-XXXX). bj зарегистрирует эту установку на сервере лицензий lic.zetit.ru и дальше сам продлевает ключ раз в сутки. Серверу bj нужен доступ по HTTPS к lic.zetit.ru.

    + + +
    {{if .License.Online}}{{end}}
    +
    +

    Установка {{if .Settings.License.InstallID}}{{.Settings.License.InstallID}}{{else}}будет создана при первом обращении{{end}}{{if not .Settings.License.LastSync.IsZero}} · последнее продление {{.Settings.License.LastSync.Format "02.01.2006 15:04"}}{{end}}

    + {{if .Settings.License.LastMsg}}

    {{if .Settings.License.LastOK}}✓{{else}}✗{{end}} {{.Settings.License.LastMsg}} ({{.Settings.License.LastAttempt.Format "02.01.2006 15:04"}})

    {{end}} +
    + +
    +
    + Офлайн-ключ для закрытого контура +

    Если у сервера bj нет доступа в интернет, вставьте офлайн-ключ, выданный поставщиком. Он проверяется по подписи без связи с сервером лицензий и не продлевается.

    +
    + +
    +
    +
    +
    +
    + + {{/* ============ ОФОРМЛЕНИЕ (логотип отчётов) ============ */}} +
    +

    Оформление отчётов

    +
    +

    Логотип организации в шапке отчётов

    +

    Логотип показывается справа в шапке всех отчётов для регулятора (слева — логотип MOEX МОСТ / НРД) и на титульной сводке пакета тестов. Встраивается прямо в HTML — отчёт остаётся автономным. Если не загружать — используется стандартный логотип «Фонтвьель».

    + +
    +
    +
    Сейчас в отчётах:
    + логотип +
    +
    + {{if .Settings.Branding.LogoDataURI}}Загружен свой логотип{{if .Settings.Branding.LogoName}}: {{.Settings.Branding.LogoName}}{{end}}.{{else}}Используется стандартный (встроенный) логотип.{{end}} +
    +
    + +
    + + +
    +

    Форматы: PNG, JPG, GIF, WEBP, SVG. Максимум 512 КБ. Рекомендуется горизонтальный логотип (высота в шапке — 28px).

    + + {{if .Settings.Branding.LogoDataURI}} +
    + +
    + {{end}} +
    +
    + + {{/* ============ ПЕРСПЕКТИВНЫЕ МОДУЛИ (под паролем) ============ */}} +
    +

    Перспективные модули

    +

    Модули для будущих профилей внедрения «автомат» и «полуавтомат» (когда используется собственный личный кабинет, а не ЕСИА Финанс). Пока не в проде — скрыты, чтобы не мешать текущей работе. В профиле «через Финансы» они не нужны: проверку и блокировку бумаг закрывает сам ЕСИА Финанс.

    + +
    +

    Что здесь будет

    +
    + + + + +
    QUIK — Модуль неторговых порученийБлокировка/разблокировка и проверка количества бумаг в торговой системе — для собственного ЛК.
    Обмен FancyПроверка заявки по учёту клиента через БД («кран») перед отправкой в НРД — для фирм без ЕСИА Финанс.
    +
    + + {{if .AdvancedUnlocked}} +
    +

    Доступ открыт

    +

    Разделы «Обмен Fancy» и «QUIK МНП» появились в меню слева. Это незавершённые модули — меняйте настройки, только если понимаете последствия.

    +
    +
    + {{else}} +
    +

    Открыть модули

    +

    Введите пароль администратора, чтобы временно открыть перспективные разделы. Доступ запоминается в этом браузере до нажатия «Скрыть».

    +
    + +
    + {{end}}
    @@ -368,17 +1088,29 @@ {{end}} diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 8fe4dac..af55c40 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -16,7 +16,7 @@ .wstep.current .lbl { color:var(--accent); font-weight:650; } .tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; } .where { font-size:12px; color:var(--accent); margin-left:8px; } -.help-block { background:var(--accent-weak); border-left:3px solid var(--accent); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; } +.help-block { background:var(--accent-weak); background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:0 8px 8px 0; } .help-block strong { color:var(--accent); } .wiz-card { max-width:680px; margin:0 auto; } @@ -34,7 +34,7 @@
    {{if .Done.TestRun}}✓{{else}}5{{end}}Проверка
    -{{if .Flash}}

    {{.Flash}}

    {{end}} +{{if .Flash}}

    {{.Flash}}

    {{end}}
    {{/* ============= ШАГ 1: PostgreSQL ============= */}} @@ -52,7 +52,7 @@

    Вариант А — для тех, у кого нет своего PostgreSQL

    Bridge-and-Join-s сам поднимет PostgreSQL в контейнере (podman-compose), создаст БД bj и накатит миграции. Подходит для дев-стенда. Для продакшена лучше указать свой DSN.

    - + ~10-30 сек
    diff --git a/internal/lkgateway/web/templates/layout.html b/internal/lkgateway/web/templates/layout.html index 1b148cb..7a94f48 100644 --- a/internal/lkgateway/web/templates/layout.html +++ b/internal/lkgateway/web/templates/layout.html @@ -6,32 +6,42 @@ {{.Title}} · Bridge&Join +{{end}} diff --git a/internal/lkgateway/xmlfields.go b/internal/lkgateway/xmlfields.go new file mode 100644 index 0000000..6923915 --- /dev/null +++ b/internal/lkgateway/xmlfields.go @@ -0,0 +1,91 @@ +package lkgateway + +// xmlfields.go — разбор сырого XML запроса/решения M2M в таблицу «все поля» с +// человекочитаемыми подписями. Используется и на карточке входящего, и в отчётах +// для регулятора (входящие и исходящие) — чтобы визуально были видны ВСЕ данные, +// которые есть в XML, а не только выбранные. + +import ( + "html" + "html/template" + "regexp" + "strings" +) + +// xmlLeafRe матчит листовые элементы значение. +var xmlLeafRe = regexp.MustCompile(`<(?:[A-Za-z0-9]+:)?([A-Za-z0-9_]+)\b[^>]*>([^<>]+)`) + +// xmlFieldLabels — подписи полей M2M на русском. +var xmlFieldLabels = map[string]string{ + "GUID": "GUID перевода", + "CreationTimestamp": "Дата формирования запроса", + "SenderCode": "Код отправителя (депозитарий)", + "ReceiverCode": "Код получателя (депозитарий)", + "Code": "Код депонента (учёт стоимости)", + "LastName": "Фамилия инвестора", + "FirstName": "Имя инвестора", + "MiddleName": "Отчество инвестора", + "DocumentType": "Тип документа (код)", + "DocumentSeries": "Серия документа", + "DocumentNumber": "Номер документа", + "ReferenceId": "Референс ценной бумаги", + "SecurityCode": "Код НРД ценной бумаги", + "ISIN": "ISIN", + "Whole": "Количество (целое)", + "Fractional": "Количество (дробное)", + "DeponentCode": "Код депонента (счёт депо)", + "AccountId": "Счёт депо", + "SectionId": "Раздел счёта депо", + "IsolationStatus": "Статус обособления", + "AgreementType": "Тип договора ИИС", +} + +// renderXMLFieldsTable возвращает HTML-таблицу со всеми листовыми полями XML. +// INN дизамбигуируется по порядку появления в документе. +func renderXMLFieldsTable(raw string) string { + if strings.TrimSpace(raw) == "" { + return "" + } + ms := xmlLeafRe.FindAllStringSubmatch(raw, -1) + if len(ms) == 0 { + return "" + } + innSeq := 0 + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + for _, m := range ms { + tag, val := m[1], strings.TrimSpace(m[2]) + if val == "" || tag == "IsM2M" { + continue + } + label := xmlFieldLabels[tag] + if tag == "INN" { + innSeq++ + switch innSeq { + case 1: + label = "ИНН передающего депозитария" + case 2: + label = "ИНН принимающего депозитария" + default: + label = "ИНН места расчётов (НРД/СПБ)" + } + } + if label == "" { + label = tag + } + b.WriteString(``) + } + b.WriteString(`
    ПолеЗначениеТег XML
    ` + html.EscapeString(label) + + `` + html.EscapeString(val) + + `` + + html.EscapeString(tag) + `
    `) + return b.String() +} + +// xmlFieldsHTML — обёртка для шаблона (template.HTML, чтобы не экранировалось). +func xmlFieldsHTML(raw string) template.HTML { + return template.HTML(renderXMLFieldsTable(raw)) +} diff --git a/internal/m2m/messages_test.go b/internal/m2m/messages_test.go index 07e12d9..1ec042e 100644 --- a/internal/m2m/messages_test.go +++ b/internal/m2m/messages_test.go @@ -152,7 +152,8 @@ func TestValidatorsNegative(t *testing.T) { {"UUID без дефисов", m2m.UUID("c02a1d5ec2af4799bab4953f133c5133aaaa")}, {"UUID с не-hex символом", m2m.UUID("c02a1d5e-c2af-4799-babZ-953f133c5133")}, {"UUID короткий", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c513")}, - {"SecurityCode короткий", m2m.SecurityCode("ABC")}, + {"SecurityCode пустой", m2m.SecurityCode("")}, + {"SecurityCode длиннее 12", m2m.SecurityCode("ABCDEFGHIJKLM")}, {"SecurityCode с lowercase", m2m.SecurityCode("mm0766162534")}, {"IdentityDocSerial пустой", m2m.IdentityDocSerial("")}, {"IdentityDocSerial с пробелом", m2m.IdentityDocSerial("45 12")}, diff --git a/internal/m2m/validators.go b/internal/m2m/validators.go index a2f8506..5e2f676 100644 --- a/internal/m2m/validators.go +++ b/internal/m2m/validators.go @@ -82,11 +82,13 @@ func (u UUID) Validate() error { return nil } -// Validate проверяет, что значение соответствует SecurityCodeType -// (ровно 12 символов из [0-9A-Z_/-]). +// Validate проверяет, что значение соответствует SecurityCodeType: символы из +// [0-9A-Z_/-], длина 1..12. Код НРД бумаги НЕ обязательно 12 символов и НЕ равен +// ISIN — напр. официальный тест-код Сбербанка на TEST3 = "SBER/03" (7 симв., +// ответ НРД от 15.08.2026). Раньше требовали ровно 12 → падало на коротких кодах. func (c SecurityCode) Validate() error { - if len(c) != 12 { - return fmt.Errorf("%w: SecurityCode длина %d, ожидается 12", ErrInvalid, len(c)) + if n := len(c); n < 1 || n > 12 { + return fmt.Errorf("%w: SecurityCode длина %d, ожидается 1..12", ErrInvalid, n) } if !reSecurityCode.MatchString(string(c)) { return fmt.Errorf("%w: SecurityCode %q содержит недопустимые символы", ErrInvalid, string(c)) diff --git a/internal/m2mcore/enrich.go b/internal/m2mcore/enrich.go index 9d4a8f1..8376ffc 100644 --- a/internal/m2mcore/enrich.go +++ b/internal/m2mcore/enrich.go @@ -76,6 +76,24 @@ func EnrichRequest( store FansyStore, claim ClaimInput, codes SenderReceiver, +) (*m2m.M2MTransferRequest, error) { + req, err := EnrichRequestUnchecked(ctx, store, claim, codes) + if err != nil { + return nil, err + } + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err) + } + return req, nil +} + +// EnrichRequestUnchecked — шаги 1–3 EnrichRequest без проверки формата: пакет нужен и +// тогда, когда данные заявки некорректны (журнал заявок, отказ через учётную систему). +func EnrichRequestUnchecked( + ctx context.Context, + store FansyStore, + claim ClaimInput, + codes SenderReceiver, ) (*m2m.M2MTransferRequest, error) { client, err := store.GetClientByID(ctx, claim.InvestorClientID) if err != nil { @@ -160,8 +178,5 @@ func EnrichRequest( series := m2m.IdentityDocSerial(client.Document.Series) req.Data.InvestorInformation.IdentityDocument.DocumentSeries = &series } - if err := req.Validate(); err != nil { - return nil, fmt.Errorf("m2mcore: собранный M2MTransferRequest невалиден: %w", err) - } return req, nil } diff --git a/internal/nsdadapter/igw/pack.go b/internal/nsdadapter/igw/pack.go index 4873f66..282d7fc 100644 --- a/internal/nsdadapter/igw/pack.go +++ b/internal/nsdadapter/igw/pack.go @@ -38,11 +38,18 @@ import ( // config — содержимое config.xml внутри пакета. type config struct { - XMLName xml.Name `xml:"config"` - Name string `xml:"name"` - Package string `xml:"package"` + XMLName xml.Name `xml:"config"` + Name string `xml:"name"` + Package string `xml:"package"` + BusinessLine string `xml:"businessLine,omitempty"` } +// BusinessLine — бизнес-линия для config.xml. С версии ИШ 107.0.0.904 при M2M +// ОБЯЗАТЕЛЬНО указывать "MOST"; на версиях младше — НЕ допускается. Пусто (по +// умолчанию) → элемент опускается (omitempty), т.е. совместимо со старым ИШ. +// Оператор ставит "MOST" в настройках НРД после обновления ИШ до 107.0.0.904+. +var BusinessLine string + // PackRequest упаковывает M2MTransferRequest в ZIP-архив для ИШ. // docFileName — имя XML внутри архива (например "M2MTransferRequest.xml"). // Возвращает байты ZIP, готовые к отправке через POST /api/package/{channel}/file. @@ -76,6 +83,51 @@ func PackXML(xmlBytes []byte, docFileName, packageType string) ([]byte, error) { return packZIP(xmlBytes, docFileName, packageType) } +// PackDepoOrder упаковывает XML депозитарного поручения (форма MF010, операции +// 16/16-1) в пакет ЭД типа ORDXM для отправки в НРД: config.xml с +// K и БЕЗ businessLine (это не M2M, а классическое поручение +// депо; см. «Инструкция по взаимодействию с НРД через Web-сервис», разд. 8.1). +// ИШ сам подписывает документ ГОСТ-подписью при отправке через REST API. +func PackDepoOrder(xmlBytes []byte, docFileName string) ([]byte, error) { + if len(xmlBytes) == 0 { + return nil, errors.New("igw: PackDepoOrder: xmlBytes пустой") + } + if docFileName == "" { + docFileName = "Poruchenie.xml" + } + return packZIPWith(xmlBytes, docFileName, "K", "") +} + +// packZIPWith — как packZIP, но с явными package и businessLine (без глобальной +// BusinessLine), чтобы слать не-M2M документы (напр. поручение депо, package=K). +func packZIPWith(xmlBytes []byte, docFileName, pkg, businessLine string) ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + fw, err := w.Create(docFileName) + if err != nil { + return nil, fmt.Errorf("igw: create %s in zip: %w", docFileName, err) + } + if _, err := fw.Write(xmlBytes); err != nil { + return nil, err + } + cfg := config{Name: docFileName, Package: pkg, BusinessLine: businessLine} + cfgBytes, err := xml.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, fmt.Errorf("igw: marshal config.xml: %w", err) + } + cw, err := w.Create("config.xml") + if err != nil { + return nil, fmt.Errorf("igw: create config.xml in zip: %w", err) + } + if _, err := cw.Write(cfgBytes); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, fmt.Errorf("igw: close zip: %w", err) + } + return buf.Bytes(), nil +} + func packZIP(xmlBytes []byte, docFileName, packageType string) ([]byte, error) { var buf bytes.Buffer w := zip.NewWriter(&buf) @@ -89,8 +141,8 @@ func packZIP(xmlBytes []byte, docFileName, packageType string) ([]byte, error) { return nil, err } - // 2. config.xml - cfg := config{Name: docFileName, Package: packageType} + // 2. config.xml (businessLine добавляется только если задан — см. BusinessLine) + cfg := config{Name: docFileName, Package: packageType, BusinessLine: BusinessLine} cfgBytes, err := xml.MarshalIndent(cfg, "", " ") if err != nil { return nil, fmt.Errorf("igw: marshal config.xml: %w", err) diff --git a/internal/nsdxml/codec.go b/internal/nsdxml/codec.go index 31a822c..de60ecf 100644 --- a/internal/nsdxml/codec.go +++ b/internal/nsdxml/codec.go @@ -48,17 +48,189 @@ func CharsetReader(charset string, input io.Reader) (io.Reader, error) { return nil, fmt.Errorf("nsdxml: неизвестная кодировка %q", charset) } +// nsPrefixes — пространства имён НРД и их короткие префиксы. Объявляются один +// раз на корневом элементе; вложенные используют префикс (req:Header, typ:GUID). +// Так требует НРД: повторное xmlns="…" в каждом теге (как делает стандартный +// encoding/xml) НРД считает плохо читаемым и потенциально ломающим парсинг. +// Порядок — для детерминированного объявления на корне. +var nsPrefixOrder = []struct{ uri, prefix string }{ + {"http://nsd.ru/schemas/m2m/request", "req"}, + {"http://nsd.ru/schemas/m2m/decision", "dec"}, + {"http://nsd.ru/schemas/m2m/response", "resp"}, + {"http://nsd.ru/schemas/m2m/handbook", "hb"}, + {"http://nsd.ru/schemas/m2m/handbook/request", "hbreq"}, + {"http://nsd.ru/schemas/m2m/participant/form", "pf"}, + {"http://nsd.ru/schemas/m2m/types", "typ"}, +} + +const xsiURI = "http://www.w3.org/2001/XMLSchema-instance" + +func nsPrefixFor(uri string) string { + for _, p := range nsPrefixOrder { + if p.uri == uri { + return p.prefix + } + } + return "" +} + +// rootSchemaLocation — значение xsi:schemaLocation для корневого элемента +// исходящих сообщений (как в эталонных пакетах НРД). Пусто — не добавляем. +func rootSchemaLocation(rootSpace string) string { + switch rootSpace { + case "http://nsd.ru/schemas/m2m/request": + return "http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd" + case "http://nsd.ru/schemas/m2m/handbook/request": + return "http://nsd.ru/schemas/m2m/handbook/request M2MTransferHandbookRequest.xsd" + case "http://nsd.ru/schemas/m2m/participant/form": + return "http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm.xsd" + } + return "" +} + // Marshal сериализует v в XML, добавляет пролог с encoding="windows-1251" -// и преобразует поток в windows-1251. +// и преобразует поток в windows-1251. Пространства имён выносятся в префиксы +// на корне (prefixize). func Marshal(v any) ([]byte, error) { utf8Body, err := xml.MarshalIndent(v, "", "\t") if err != nil { return nil, err } - withProlog := append([]byte(xmlProlog), utf8Body...) + prefixed, err := prefixize(utf8Body) + if err != nil { + return nil, err + } + withProlog := append([]byte(xmlProlog), prefixed...) return EncodeWindows1251(withProlog) } +// prefixize переписывает XML, сгенерированный стандартным encoding/xml +// (с xmlns="…" в каждом элементе), в форму с префиксами, объявленными один раз +// на корне: … +// … … . Работает на уровне xml-токенов +// (не строк) — надёжно к структуре. Сохраняет отступы из MarshalIndent. +func prefixize(body []byte) ([]byte, error) { + // Проход 1: какие namespace реально встречаются (для объявления на корне). + used := map[string]bool{} + xsiUsed := false + { + dec := xml.NewDecoder(bytes.NewReader(body)) + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if se, ok := tok.(xml.StartElement); ok { + if se.Name.Space != "" { + used[se.Name.Space] = true + } + for _, a := range se.Attr { + if a.Name.Space == xsiURI { + xsiUsed = true + } + } + } + } + } + + dec := xml.NewDecoder(bytes.NewReader(body)) + var out bytes.Buffer + depth := 0 + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + switch t := tok.(type) { + case xml.StartElement: + name := qname(t.Name) + out.WriteByte('<') + out.WriteString(name) + if depth == 0 { + // Объявляем префиксы используемых namespace один раз на корне — + // каждое объявление на отдельной строке с отступом (как в + // эталонных пакетах НРД, для читаемости). + const nsIndent = "\n " + for _, p := range nsPrefixOrder { + if used[p.uri] { + out.WriteString(nsIndent + `xmlns:` + p.prefix + `="` + p.uri + `"`) + } + } + // xsi объявляем, если он используется в атрибутах ИЛИ если для + // корня есть schemaLocation (как в эталонных пакетах НРД). + schemaLoc := rootSchemaLocation(t.Name.Space) + if xsiUsed || schemaLoc != "" { + out.WriteString(nsIndent + `xmlns:xsi="` + xsiURI + `"`) + } + if schemaLoc != "" { + out.WriteString(nsIndent + `xsi:schemaLocation="` + schemaLoc + `"`) + } + } + // Атрибуты, кроме объявлений namespace (xmlns / xmlns:*). + for _, a := range t.Attr { + if a.Name.Local == "xmlns" || a.Name.Space == "xmlns" { + continue + } + an := a.Name.Local + if a.Name.Space == xsiURI { + an = "xsi:" + a.Name.Local + } else if pfx := nsPrefixFor(a.Name.Space); pfx != "" { + an = pfx + ":" + a.Name.Local + } + out.WriteString(" " + an + `="`) + writeXMLValue(&out, []byte(a.Value)) + out.WriteByte('"') + } + out.WriteByte('>') + depth++ + case xml.EndElement: + depth-- + out.WriteString("') + case xml.CharData: + writeXMLValue(&out, t) + case xml.Comment: + out.WriteString("") + } + } + return out.Bytes(), nil +} + +// qname возвращает имя элемента с префиксом НРД (typ:GUID) либо без префикса +// (корень/неизвестный namespace). +func qname(n xml.Name) string { + if pfx := nsPrefixFor(n.Space); pfx != "" { + return pfx + ":" + n.Local + } + return n.Local +} + +// writeXMLValue пишет текст элемента/атрибута, экранируя XML-спецсимволы. +// Whitespace (отступы MarshalIndent) проходит как есть. +func writeXMLValue(buf *bytes.Buffer, b []byte) { + for _, c := range b { + switch c { + case '&': + buf.WriteString("&") + case '<': + buf.WriteString("<") + case '>': + buf.WriteString(">") + default: + buf.WriteByte(c) + } + } +} + // Unmarshal разбирает XML (windows-1251 или UTF-8) в v. Использует // CharsetReader для перекодирования входа, если в прологе указана // windows-1251. @@ -67,3 +239,14 @@ func Unmarshal(data []byte, v any) error { dec.CharsetReader = CharsetReader return dec.Decode(v) } + +// UnmarshalDefaultNS разбирает XML, подставляя defaultNS как пространство имён +// для элементов БЕЗ префикса и без xmlns. Нужно для входящих сообщений НРД, +// где корневой элемент идёт без namespace (напр. с +// префиксами только на детях), а наша структура ждёт корень в своём namespace. +func UnmarshalDefaultNS(data []byte, v any, defaultNS string) error { + dec := xml.NewDecoder(bytes.NewReader(data)) + dec.CharsetReader = CharsetReader + dec.DefaultSpace = defaultNS + return dec.Decode(v) +}