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 0000000..efd1cf2 Binary files /dev/null and b/DOC/m2m_participant_testing_2026-07-20/tehraboty-TEST3-daty.docx differ 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 0000000..c0c22f8 Binary files /dev/null and b/DOC/m2m_participant_testing_2026-07-20/Анкета на тестирование с участником (приложение 1).xlsx differ diff --git a/DOC/m2m_participant_testing_2026-07-20/Инструкция по подключению к тестированию с участником 1.docx b/DOC/m2m_participant_testing_2026-07-20/Инструкция по подключению к тестированию с участником 1.docx new file mode 100644 index 0000000..1d16f2a Binary files /dev/null and b/DOC/m2m_participant_testing_2026-07-20/Инструкция по подключению к тестированию с участником 1.docx differ 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 0000000..bbe7b41 Binary files /dev/null and b/DOC/m2m_participant_testing_2026-07-20/Сценарий тестирования (приложение 2).xlsx differ diff --git a/DOC/m2m_participant_testing_2026-07-20/Тестирование при подключении.pptx.pdf b/DOC/m2m_participant_testing_2026-07-20/Тестирование при подключении.pptx.pdf new file mode 100644 index 0000000..b816c86 Binary files /dev/null and b/DOC/m2m_participant_testing_2026-07-20/Тестирование при подключении.pptx.pdf differ diff --git a/DOC/naufor-spravka-standard/Pril1.docx b/DOC/naufor-spravka-standard/Pril1.docx new file mode 100644 index 0000000..d0f4f41 Binary files /dev/null and b/DOC/naufor-spravka-standard/Pril1.docx differ diff --git a/DOC/naufor-spravka-standard/Pril2.docx b/DOC/naufor-spravka-standard/Pril2.docx new file mode 100644 index 0000000..555cc09 Binary files /dev/null and b/DOC/naufor-spravka-standard/Pril2.docx differ diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..18dd04d --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,126 @@ +# Product + + + +## 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" .}} +
+
+

{{.Title}}

+

Войдите, чтобы посмотреть свои ценные бумаги и подать заявку на перевод к другому брокеру.

+ {{with .Flash}}
{{.}}
{{end}} +
+
+
+ +
+

🔒 Соединение защищено. Мы не запрашиваем пароль по телефону или в письмах.

+
+

Логин и пароль выдаёт ваш брокер. Не можете войти — обратитесь в поддержку брокера.

+
+{{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,iVBORw0KGgoAAAANSUhEUgAAA8EAAADBCAIAAABgwiXKAAAACXBIWXMAAC4jAAAuIwF4pT92AAAgAElEQVR4nO3dz09bd77/cceOwTVzTmjS6Yy+5g/AbO7iSjGbjhQJ2LSaKpDFJJMoPzaZiURZpYmaZkWI0mbFIHGbTX6oUbMJRHN1ZwNIkaYbjO6V7l18Mfouviss3TZNAj4X1+Da8xU5+bqubezzOZ+P7c/Hfj40i5SBw/HB4Jff5/15fw69+OMf9taSAQAAAADeBLlOAAAAgBAyNAAAACCGDA0AAACIIUMDAAAAYsjQAAAAgBgyNAAAACDmMNcLQsKD8UO2zTUDAADd4KfUetFxqh8oGRpi7Bs3exPDXDQAANANfjh7uuZWKvRyAAAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGLI0AAAAIAYMjQAAAAghgwNAAAAiCFDAwAAAGIOa3K9woNx+8ZNDU5EU5mZ6fxGqtuvAgAAgB50ydCHbLs3MazBiWjqkG13+yUAAADQBr0cAAAAgBgyNAAAACBGl16OcsVMJp9a1+d82iUcHwrSwgEAAKAfHTN0PrX+8twZDU6kzY59/Q094gAAABqilwMAAAAQQ4YGAAAAxJChAQAAADE69kPXdHT+q0OW7AI7Z252by0ZisX679yVP6XXVy4XHScyMtp3/qLkoQrpza3rn8qfEgAAAFrAmAydT6WsySmZIxTSm3tryTf/SAcCAcnletnFp0XHCQQCe8nV/jt3JQdobF27KvPlAAAAaCVjejl2Ht4vZjIyR9iemS7925mblTlUMZMpHaHoODuPHsgcLZ9azz5bkDkCAAAAWsmYDC0ZVXeTq7mV5dJ/7q0ld5Orvo+28+iBW8x++59y+b483AMAAEB/Jq0plImq1YVn36XoYiaz8/D+Lz4ike93k6tuhwkAAABMYVKGLjpOxlfJtmZO3VtLZhef+jjazqMHbid0OWdutpDe9HE0ya4SAAAAtJ5hs+2yzxZ8RNWDcqqP/FpdhP75aH8RPhpFaAAAABOZNx9aNKrWyamFdFq0FF2zCO3yke8pQgMAAJjImNl2JdlnC9YnU6HYgMfPr59TnbnZ6Pgpj4eqU4R+e7S/zPZ/4XXyNEVoKFdIb+ZT6/lUKp9aL2YyhfRm+eLXkvBg/JBtH44NhAYGeo4nwvEhyeGMAAB0G/MytFBUbZhT3VK0xxhdpwjtEsr3FKFV0fBKRscnvL/Nk1TMZHIrS7mV5b3kav3nZ0l+I7W/JCDw869GeDDekxjuTSQiI2OtOe1ykj/BoGX1Xbikyfk0/NF30pvnUCx20B/PQnozuyg1srM1v0SSP446V0DmiVT/sCXyF7kZPJ68S+Yh9BxPSO7zoIrvH7QmD0HPJ1IgEJDcFaQFjMzQ3qOql2e2MzcbGRlrWIdrWIR+ezRv+Z4itEIaZuie44lmv/y70Tm7uKDkiZTfSOU3UjuPHgQtKzIy9s74RCv/ssv/BA/ZtveX7YZkzqfhj35vLdkx7597jicOuuyHLFu3t0Y17Tx6UD72VNSRzz6v8xUy0crL8/mnzU09//p5/2WUeQjW5JTpGVqTh6DnE8mIDG1eP7TrtYeN/Tzm1EI67WUyXcMitCv7bMHL5GmK0PBtf2f4a1e/P/HB1vVPlb8TKzpO9tnCy3Nnvjvxgb/BNW2RmZmW3IMJagVtO3pyQuaQLSiMvXkj6j9ABwKBd9S9cwNgHFMztJdNUrzn1IaTpz0WoT1+X4rQ8MdNz9+d+F322YLHtg2J75Xeuv7pf//zPxnxfk9+u1AoFxkdlTlkfiPV7PdFe2v+d9raf4AjoywkALqZqRm6YVQVyqkNX4A9FqFdDfM9RWiIKmYy2zPTbnpu5cUrOo4zN/vdiQ9yK0ua/9B8z2hHk+z3yFmWzLGb/azLLUsVoSMjUm8SAJjO4AxdP6qK5tQ6pWihInTD704RGqJyK0vfn/igjXXWQjr96sqffjh7WvOQ6qXFC60kuURVstHCw/GlMnpbFuAC0IfBGbpOVPWRU+uUooWK0K46+Z4iNLwrZjKvrlx+deVPze7c8GJvLfni9x+Kvp9s8RnqXy/vKpLtHLmV5ea1c+RWlmR+rWjkAGB2hj4oqvrLqTVL0T6K0HXOgSI0vMun1r/fb6JobilOSNFxtm/fenXlsrYL+LZnpjU4C7wVGRkLxWIyV0OyZbmO3aTUn+LouNSKSQAdwOwMHQgEtq5X3r31nVOLjpOpegH2UYR27a0lq8caUISGR9nFpy8+/kiH8nO13Mryy7On86l13U7MbTvht0wrsu0cci3L9Y4sccvCHQGp9HQAmMf4DF29X3d1qvauYr/uQlpqaGLF12YXn1KEhhfbM9Nb1z/V+VLlN1Laxuidh/dZXKgPyXptk5pz8qn1mlt4ekSABrpeoBMydEVUzS4+lfnL6G6SUvPfPlTke8pj8GLr2lUjxrQVHefl2dNepqG32H7DCR0d2gjHh2TaOYqO04wYLTl8WrLPG0Bn6IQMXR5V5XNqqRS9v/ul9Byx0vnIh3t0g61rV1s8vU7Gfow+d0bDZXy5lWUNw33XktxCUrJxuaY9iacHjRwAXJ2QoUtRVVVOdcvPkkVoVynfU4RGQ2YF6JKta1c1bOqoXtuAdtGtnaOQ3sxvpHx/OXsTAnB1SIZ2o6qqnOru160qzThzsxSh0dDOw/smBuhSU4duMTq/kdJ5DF9XCcUGwoNx34+4kE6rfXbllqVCORM5ALg6JEO7QrEBVYcK2rbkDlvlFJ4YOlJuZWn79i1zH1nRcbauXdVt4J0zN6vtDL5uI12KVjmd40eJo4VisXB8SOHJADBXh2ToUCwWHT9lTU4pOVr05EQ4PtR34ZKSo1mTU72J4Z7jCSVHQ+cppDe3zN9gL7+RkhmJ0ww1p1WiLSKjkhPulLVzFDMZmflIdEIDKOmQDO2mZ1VR1fpk/2h95y/Kl6LdcF86Q6Da62tX9ZwDLSq3sqxb+4Tbl6XBiXS7UGxA5o9zfiOlal6hZHf1ry5cVHIaADrA4Q54DOHBeGndtzU59fLcGZmj9Z2/6LZeBG2778IlyR7r/jt33X+4+Z750Lpp0v0B75sAO3OznfSs2L59qycxrNXNbmdutjcxrMGJvBWKxZQ86wrpTd+rLMKD8UMq9qkW+kFHxydknuq55SUl9wZl2kLCg/GOacwLxWJNeiz0unQVVX9MDNUJGdq+cbP0b8moGrSs8oJx3/mL+xuA+60R9hxPlL94y+d7KPfe4ydtvKiSm/joaXtmur1XtYK7Y6jkeDWFouOnlJyMMzfr+8lj37jZ+vcVb7og/O8c9OPKctszdCetJlTY/Yhu1pY/JvowvpejIqdKdk30XbhUXkF0S9G+j1ZxJnRFo8Jr89ugq9Xc5b69MjPTLC5su6BtR0b8b02yt5aU/yFKNnJIdnUD6DDGZ+jqxNybGPb3lzpoWX3nK3vd3rR2+Nlkqzrc0xWNcrvJ1U7t7dEtsxYdhwHtOpDJ0EoGReeWaeQAoIzZGbpmTg0EAkfKuju8qyhCu4K27S/41vyq3sRw9CSzRRFo9rY7QcuKnpw48tnnx77+5rf//p//6//8X/d/v3n+92Nff2NNTjX1lkjRcXTbrnzn0QNVi9LgW3T8lMxCbfkJdzIpvI/VhAB+yewMfVC6DcUGRKNqzSK0Kzp+SrQUfVC4Lw39QJdrXhE6MjJ6dP6r3/7Hf/V/cbfvwqXexHD5O8NQbKA3MWxNTr33+Mlv//0/j3z2ub/bLA3tLyTQrH2iIztnjCMzG04yQ+8mV2UG4DDVDkAFgzN0nZzqI6rWLEL/fDTBUnSdz/eR79F5mlGE7jme+M3zvx+dv+fxxd5t9//N82+PfPa5wh2FXBqWovfWksp3jYaoyGjb2jlkInhkZNT7sB0AXcLgDF0/1wpF1TpFaJdQKbp+uKcUjXxqXW0ROmhZR+e/eu/xE3/9mn0XLr3//FvJXtVquq0sdGeGsLiwvSIjY1LtHBINzTL5W/lvB4AOYGqGbphThaJq/SL026N5LkU3/ExK0V1u56HKAm14MP7rf/2b5I3moG0fnb935LPP1Z1XoJBO61b3LaTTulXHu5BcO4fPZ1Q+te57nHbQsmjkAFDN1AztJdF6jKoNi9Auj6VoL+GeUnSXU5gsw4PxY37Lz9X6Llzqv/Olwh9OdnFB4dGU2Hl4n8WF7SWzOK/oOP42npRr5BijkQNANSMzdGRk1ONMby9R1b5x0+PfRy/B/d0v7no5FKXorpVbWVK1s7cboNW+ukfHTymM0bmVZd16J4qOsz0zrcGJdK9wfEhmJau/NJxblmjkkOvhBtCpjMzQ3kfXNYyqoVjM+55hDUvR0ZMT3iuClKK7k0xDZ7mgZSkP0K7o+Ckvd2Y80nAZX25l2V8tE6q0uJ2jkN7Mb6T8fTsaOQAcxLwMLZRTG0ZVhQM3RGMxpejutLumJr29O3+vefeXj9y4GR6MKznUXlLHfWS2rjPnrp1kNs0upNP51LrQl0gVoQnQAA5gXoYWLd+GYgMHj5EWKEK7ouOnDtqcQjTcu49F+Uwx6ExmYVO5vvMXPbYz+dbvrSupIVXvGdTaX1z48L6GJ9YlwvEhmTdpou0cuxJjcN6RiPsAOpthGdpHTnUDR82oqnADQn+9GaHYQN+FSz7OAYYSrZ/VFLSsFmwaH44PKblPUkin9VzD58zNMueujWRK0UJ15WIm43tBYSgWa/abVQDmOmzWmfvrIXb3kqjY1cJHEdrVmxjuOZ6omO/rL9y7+X5/RzdFi8ygOSWNDV5GMSphfTKVfaZgsEY+ta5qcohCRcfJzEyrKrdDVGR0bPv2LX+XLb+RKqQ3PT6p5MZCd2wjhzM3q2SnJ2tyqgVv6aGtl+fOKDm1nuOJ9x4/Me7nbFId2ndOrVmKlvm1r/5a3wsE3Xzv+0xglp9UVGRlCnhCVLXs51M+l3M1W/bZAosL2yUUG5Bq5/BcipaZatey3zUAJjKpDi0zyKKiFO27CO2qKEXLhHtK0e31w9nTSr7//iK8+FDDT5PfnjAyMtrKmm5kdFS+FL2bXNW2UuXMzXKzvl2i4xO+S9G7a0mP1Yc9v2+TQrGYl19qAF3LmAwtmVMroqr8K7o1OVW6hSE5pa5mqwlaQ9We2y3rrG3xnsMdP5Rgby2ZXXwq844avr0zfsp3hnZHjzdsapIZx/4rdRMeAXQkY3o55Kcpl7omwoNx+ZdMtxStJNzXWfWITqKkbaD1RdODBtF4p+qNSpNkZqZZXNgWQduWeU/opdFZZhx7ZJSpdgDqMSND952/qOT+tRtVbc9btNRnTU6pOhpd0fCo9YvzNG91kN8Opug43AVqF7kM3Tgf+15QGB6Ma7gQFoBWDMjQCid5BW373fl7qjJBb2JY4T4XlKLRkHxJ2AeZbZlbwH0rK/l9dh490HMAX8eTaRZq2OicT637buRgNSGAhozc61uG2qFgCo8WtO1DLRlYBgjRvBoXtG0l94JeX2PnwjYI2rbv2S9Fx6lfZs4u+l8OSyMHgIYMyNBFx9l59EDNoTKZV1cuq5pmlU+tvzx7WlUnpTM3q2QHO2jrJxUbrKBand1DvdtbS8oMEoZvkVGJdo667c6+f6Atnn4DwFBm1KH352moiKpv7timVfU+bs9Mq8r3xUyGnYc7XmiAV+VmOaKiFL3N4sJ2iIyM+e7GqZOSZfbVb/H0GwCGMiNDuzuKyR7k/+fUvbWkfCl6N7nqThtQku93Hj1gPnTHO2QZ2avzD8eAWBmOD8kvLiyk06pueUGI767oouMctH++77HQ3TDSEYASxvRDZ58tSC76Kc+pW9dlex9LxWz5fE8RGh61Zd2btrsMVlCzuPDhfRYXtp5MO8dBTc++m6EjI6Ot2UsfgOlMWlPo/MV/D0ZFTi2k09nFp76PVipCuyTzPUVoeFRIp1vfbFDYNCNTKllcWHScbelbXhAVGRnzPf6lZjtHIb2Z3/D53o+JHAA8MilDy0TV6pwq0xVd/bW+8z1F6O5xWEU/9N6amhWx3u1Kf8fwYLw1p6pkcaGXqcNQznf7RCGdrm7nyC37XE0YtCwaOQB4ZNhsO39RtWZO9V2KrihCu3zne2duliJ0l1Cy0n832dI9/wrpTflxMa0c2qhkcSFaT6b6W936vOt3a0wCNADvDMvQ/qJq5s0AjeqP+ytFH9RL7SPfF9KbrGHqKvIV2R8lepB8kJmwWxKOD7XshJUsLkTrheNDvts5fvzlrYNiJuP7ZoJMZzaAbmPeHiui3YqF9Gb2We0c4KMUnV18elBZzke+l+nwhonkx9sVHUemlV+Uku91uLU7HSpZXIjW810Drrgx6HssNI0cAIQcNu5y5VaWd5Or3vfrrp9TMzPT+9NJPd9rrl+6fn3t6nuPn3g8VJ1wj04Vjg/Jt9s6c7PR8VMtuEJ13jEK6VG0u75H7uLCreuftvKbQt6vLlz0fV8ut7JUir++f8XeacmvlSasySlrcqp7Hi+a5NjX33jPY53HyL2+vfdgNMypQpukNIwUQpOnKUJ3IfkVb/JTZTwqZjLyQ9ldrezlcEXHT7VsISNUCcUGfP/USusEZBo5mMgBQIh5dehSVPXy1sdLTt15eL/v/EUvpWgv2d2Zm/VyYhShNaGqEuNx5oaq9+ui9098ULXatV1bvtk3br48d6Yt3xq+Rccntm/f8vHVuZUldzmp70aOUCzW+jd7AIxmZIb2GFU95lS3FN0wS3m8r+0x31OE1kTr72ZGRkbl2zmKjvPqymXvjUOicitLqla79qoovfv5vonh6MkJ3qmaJTI65i9DuxPuZHql6IQGIMrIXg6PXROvr3ndjLDhft1C97Ublqt3k6u8tHctVXXZvbXkludnuJB8al3hkSOjbYsm9o2bLC40Syg24LvfyZ1w53uLbxo5AIgyNUM3jKo1pzgfpGFXtNBWgg3zvcz2LjCdwnJX9tmC8hidT62/PHta1czy8GBcyVRsf4K2zaop4/jOsj+uLOdWlvw9dcODcRo5AIgyOEPvrSXrrKwSzak7D+8fNJnOx1aCdYrWQuEenSdo2wpbhN0YrWoD8N3kqsIAHQgE+i60eVRz34VLLC40i8yEO9/jzClCA/DB4AxdJyj7yKlFxzmoR1moCO3Kb6QOyvcUoaH2BTv7bOHl2dPV2x2LcuZmX547ozBAazJt12bnQqPIvMmU2FqFZmgAwszO0AcN+fKXU2tukuKjCF3nHChCw620+d6Srab8RurFxx/tT9LwVZDeTa6++P2Hyt/dNXtyiEfu4sK2nwa8a/Esl/Z2HAEwl9kZumZUlcmp1aVoH0VoV818TxEarmb06Tpzs9+f+GB7Ztr7fpm5laUfzp5+ee5MfiOl/HysT3TpRWZxoVn233218OfV9o4jAIYyPkNXR1WZjSEqStGF9Ka/IrSrIjHnVpYoQsPVpJTgro797sTvXvz+w+2Z6dzKUnWe3h8Ls/h069rV//7nf3p15U9Nek5GT07oU9tjcaFZ3rRztK65gql2APwxdT50ufKtj7OLTyUras5fZvu/uFv6t0x7qJvvS+e2rWjXN3SAZu9Hnd9I5TdSqmY8+6BPEdrVd+FSdnGhGeV2NENkdLQ1A0AjI6M6dBwBMJHxdWg3qpaqxfLNEtlnC+7yLCVbCZZaVD1u0YLuER0/pbYrWh/W5JSGDaYsLjRIy9o52rWPJoAO0AkZuhRVVeVUt2CsZCvB/Xz/phZIJzSq9d+523lXJRSL9Z3XscGUxYVmaU2LBY0cAHzrkAxddJwfF5+qyql7a8ncypKqO4k7D+9ThEZNvYnhziuDHblxU9ub4ywuNEgLlvpFT07QyAHAt07oh3Ydjg+FYgNKomrQssLxoaBlKZmV656Y/HHQkfrv3P3+xAcKpzK3V/TkhM6FPXdx4fbtWxqcCxoI7//ljDW1+hAZ7d5GDmdutql3R63JqWYv5G32QwgEAse+/qY3Mdy843fAQ3h57kzzDt6aJ5KMDqlD9xxP9CaGVV3ovguXQrGBvguXlBzNmpzqTQz3HE8oORo6TNC2352/1xmPKRSL6d9zzM6FBmnq+zFN9gACYK4OydBuelYSVYOW5XZz9p2/KH/b1w337g1uyUOhUyl8+9dGQcs6On/PiDvjLC40RVO34CZAA5DUCRm6lFOVbF3Rd+GSmwOUzJQtHSEcH2I9Ew5iTU6Z/vSwb9wMx4c0OJHGWFxoCredo0kn+04zAzqAbtAJGbo86UqWoktFaNebpg7/f8HLw72GE3Ohlf0MamyPQf+dL0tz0I3A4kJT/Ko5M15CsVhTm0QBdAPjM3RFTpUsRZeK0EqOVvG1odgA1S8cJGjbxx4/MTFGR09OmBWg2bnQIJHRpnRc0MgBQJ7xGbr6hdD3vLCKIrTL90YY0ZMT1XUOStGow8QYHT05UdrX0ywsLjRCKDbQjB9TUzutAXQJszN0zZzqewFfdRHa5a9eVTMuU4pGfWbFaGtyytAA7WJxoRGU591QLGZK7z4AnZmdoQ8q6/qIqjWL0C4fpejoyYmDZkJTikZ9bozWf++V/jtfmt4OweJCI7yjulPIuNYjAHoyOEPXyak+oqo1OVVnLJdoVqjz3UOxARoxUV/Qto/O39P2eRK0rF//9d86I4iwuFB/QdtW+5aSRg4AShicoeunZKFSdCgWq7+jilApun64VzV5Gh3Pmpw6Ov+Vbk+VnuOJ959/2zG3wllcaASFGTo8GGfjWABK6JKh/5HJlP7t5eW5YU4VKkV7eRH1/kLb8PsGbdvLJojl16H8+qB7REbG3n/+rSZ9HUHLOvLZ5+89fmLERiresbhQfwrHaFCEBqCKLhk6v5Eq/Tto2w1rb17yscdSdCgW83JXOjp+yssLrTU55aXI0bAUHbSs8qRSfn3QVdy+jqPzXzVvswkvIiOjv/7Xv6naAF83LC7UnMJ2jiYNywPQhTTq5cin1kv/Ply3FN13/qLHm3Feorb3AnPDF9o6CxMrP7NRKbr8CpRfGXSnyMjYr//6t/2u/Za3dvQcTxz7+puj8/c6+A44iwv1p6R+HBkZpZEDgCqaZuh3Di45BC3Le+ptWIr2WIR2NdwE8aDpeLU/uW4puvwKkKFR6tx9//m3LUvSbnp+7/GTbtjRjcWFmouMjMn/gPQfdwPAIBpl6L1ksvTvOrfbhHJqw1K08MCNgz/fexH67efXLUWXX4HyK4Mu5ybp3/7Hf/Xf+VJmW/t6z0zLip6c+PVf/61L0rOLxYX6k++KZntCAAod1udi5laWAoG3+zWEYgORkdHcynLF54jm1FIpOvtsodb/JVCEdrml6L21GqFWNNy7iTy7+LSQTld8vOKG45srgwM1KUpqLjp+Kjp+qpDezC0vZRcX5Dvmg5YVGRnrSST2C36tXTWoyU+w78Kl3bVkUW79bssuXSgW833dDF0V+s74xE/pTd9fHo4PmfLE9jj3Jmjb2v7187h+Q+eH4P03xfSH0AFPpHY59OKPf6iZCNui/86XpVC7m1x9ee5MxVlYk1M+akWF9OZ3J35X/fHyb+ddzRMLWtb7z7/18Qc6u/h06/qnFR889vU3pfpfzU9oo/Jzgz6Kmcze2mo+ldpNrv6UWi86jpdTczds6z2e6EkMs3MbAADVfjh7umZU1qgO/SYvLpRCrbvKp6J+7K8frmYp2kcRunRi1aXo+lu01D+3io9UbGCeXaxRQQcqvBlcMBYZGSu9ydxNru5PRXQy+dQvStRuvSFo24RmAAB80ytD760ld5OrpQRp37iZW1kqr6ht377l3nIVPbL1yVRFhu6/c9f3eVqTU+Wl6IZbtBykusYctKzy6R+7yVV97hLALKXfI3pAAQBQTrt9Cp252dK/g7b97vy9ik/Yvn3rh7OnC4JdcRUDOnqOJ2QaEioGdPhoLylmMq+uXK5u0nh3/l55Pbv8agAAAEAT2mXovbXkzsP7pf/sTQz33/my+nO+O/G77ZlpoSRdPqBDfgF+6QiiPSGF9KYzN/v9iQ+qV0z23/myPNnvPLxPERoAAEBDevVyuJy52cjoWKlR2E2o1SXbnUcPdh49CA/GI6NjXpaUlhqsJYvQpaO5k0PcMO32nta3t5bMp9aro7OrYoGjG7UlTxIAAADNoGOGLjrOqz9fPvb4SamrITp+KhQbeH3lcvW0gfxGSnSq1+HYQD61LrmgKp9ad1P+1vVPJedmBC3r3fl75bF+v9PjzzUeLAAAAHSgXS+HK7+RysxMl3+kNzH8/vNvlewylX228OLjj7478YFoN4gbnbdnpr878cGLjz/aefRA/mQiI6PvP/+2oi6+df2q/LhfAAAANImOdWiXO0aj/4ufp2cEbfvo/L3d5KozNyvfKFxIp91ukJ7jCWtyqmF3h6rvW3LQ9926dvWgfg8AAADoQN8M7cbon9KbR385qqI3MdybGM6n1rOLC3vJVfl67d5a8uW5Mz3HE0du3KzZ4FFIb76+dlVVeg4PxnsSw9Hxierv5Q7rYB0hAACA5rTO0G7AffHxh0fn71UkznB86MiNtx85aC+JCsVMJp9aPyihvvlGH1Xvg+jMzdZZ2xe0rMPxoYY17HA8fsiyy0f2Vsun1l9duVy97zcAAAB0o3uGdpsu3HTbd/5izb0ARfeSyK0s5ZaXK3ZvcTlzs7nlJXc5Y52qcCgWi4yM1awl+1DMZJy5WSXd1QAAAGiBQy/++AdTmgeCltV34dJBSVpUMZPJrSxlZqark3QoFuu/czczM13dKBKKxazJKX+bhFcrZjL7PdkP7xs0guPY19/ITwYEAAAwwg9nT9eMyiZlaFfQsj8UJw8AAALZSURBVCIjY5HRUSU7GHuvAQcta78W7mtP72p1auGaI0MDAIDucVCGNqCXo0LRcbLPFtypHe5uKW5TcsMvrJn8grZ95MbNyMhozeHTJeHB+NF/uVfa9qXylN50Wjc8gZ9S6z+l03V6sgEAAGAE8zJ0ub21pFAedWdiREZGK/K0O3z65dnTNad8RE9O2DduVjeQuLXk3bVVFgICAAB0FbMztCh3U8OdRw+q25qDtn3s8ZPqGB09OVE+o9q18/D+/zx6QHQGAADoTpruU9hshXR66/qnP5w9Xb5P4f4eLv9yL2hZpY/0HE9UBOh8an1/g8PbtwjQAAAAXatLM7Rrfyb07z8sb2UOxQb2B9u9idGhWOzo/L3yz88uPn3x8UekZwAAgC7X1RnaXaG4379RFqPD8SH7xs1AIFCxP+LWtatb1z9t02kCAABAI92eoWvG6Oj4qd88/3v5/inO3Kw7CQQAAAAgQwfcGL117Woxkyl9pHyM3W5ytc523wAAAOg2ZOi38hupV1cuV3+8kN58XevjAAAA6Fpk6J/trSW3rl0t/0gxk3n153p7rwAAAKALkaF/IftsYefh/dJHMjPTNXddAQAAQDcjQ1favn0ru/jU3UiFdYQAAACo1l37FHqUmZn+RyazffuWEWcLAACAFqMOXUPRcQjQAAAAOAgZGgAAABBDhgYAAADEkKEBAAAAMWRoAAAAQAwZGgAAABBDhgYAAADEkKEBAAAAMWRoAAAAQAwZGgAAABBDhgYAAADEkKEBAAAAMWRoAAAAQAwZGgAAABBDhgYAAADEkKEBAAAAMWRoAAAAQAwZGgAAABBDhgYAAADEkKEBAAAAMYe5XhDy4+LC3lqSawYAALpBIb1Z81GSoSEm+2yBKwYAALocvRwAAACAGDI0AAAAIIYMDQAAAIghQwMAAABiyNAAAACAGDI0AAAAIObQ3vr/LmYyXDUAAADAi0Ag8P8A0o1N9blcdwQAAAAASUVORK5CYII=" 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 0000000..18f0858 Binary files /dev/null and b/internal/lkgateway/nsd-certs/nsd-prod-gost-shlyappo-20260821.cer differ 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 0000000..b29138f Binary files /dev/null and b/internal/lkgateway/nsd-certs/nsd-test-gost-petrov-20240924.cer differ diff --git a/internal/lkgateway/nsdcerts.go b/internal/lkgateway/nsdcerts.go new file mode 100644 index 0000000..a7fb49d --- /dev/null +++ b/internal/lkgateway/nsdcerts.go @@ -0,0 +1,609 @@ +package lkgateway + +// Доверенные сертификаты НРД в справочнике сертификатов активного профиля ИШ. +// +// НРД периодически меняет ключ крипто-сервера (например, в ночь на 10.09.2026 — +// сертификат Шляппо А.А. от 21.08.2026). Участник обязан ДО смены добавить +// новый сертификат в локальный справочник, а старый удалить в разрешённое окно. +// Вручную это делают через GUI «Справочник сертификатов»; у нас ИШ работает +// без GUI, поэтому bj делает это сам: +// +// - комплект сертификатов + manifest.json ВШИТ в бинарь (go:embed nsd-certs/), +// т.е. приезжает всем клиентам вместе с обновлением bj; +// - дополнительно читаются ручные .cer из /var/lib/bj/.bj/nsd-certs/; +// - root-воркер (bj-keymedia-install certs-list / certs-apply) сверяет и +// пересобирает справочник профиля: бэкап → пересборка → проверка подписи → +// откат при ошибке → перезапуск ИШ; +// - сертификат добавляется ТОЛЬКО если его УЦ уже есть в справочнике — +// боевой сертификат никогда не попадёт в тестовый профиль и наоборот; +// - старый сертификат удаляется не раньше remove_replaced_after из манифеста. + +import ( + "bytes" + "context" + "crypto/x509/pkix" + "embed" + "encoding/asn1" + "encoding/json" + "encoding/pem" + "fmt" + "log" + "math/big" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +//go:embed nsd-certs/* +var nsdCertsFS embed.FS + +const ( + nsdCertsDropinDir = "/var/lib/bj/.bj/nsd-certs" // ручные .cer оператора + nsdCertsApplyDir = "/var/lib/bj/nsd-certs-apply" // staging для root-воркера + nsdCertsInterval = 6 * time.Hour + nsdCertsStartWait = 90 * time.Second +) + +// NSDCertsSettings — состояние автообновления доверенных сертификатов НРД. +type NSDCertsSettings struct { + Manual bool `json:"manual"` // true = только по кнопке «Применить» + LastRun time.Time `json:"last_run"` + LastLog string `json:"last_log"` + LastProfile string `json:"last_profile"` + LastRows []NSDCertRow `json:"last_rows"` + LastStore []NSDStoreCert `json:"last_store"` + LastApplied time.Time `json:"last_applied"` +} + +// UpdateNSDCerts сохраняет состояние автообновления сертификатов НРД. +func (r *RuntimeConfig) UpdateNSDCerts(s NSDCertsSettings) error { + r.mu.Lock() + r.data.NSDCerts = s + r.data.UpdatedAt = time.Now() + r.mu.Unlock() + return r.save() +} + +// ---- манифест и разбор сертификатов ---- + +type nsdManifest struct { + Certs []nsdManifestCert `json:"certs"` +} + +type nsdManifestCert struct { + File string `json:"file"` + Title string `json:"title"` + Contour string `json:"contour"` // prod | test | manual + Serial string `json:"serial"` + ReplacesSerial string `json:"replaces_serial"` + ReplacesTitle string `json:"replaces_title"` + RemoveReplacedAfter string `json:"remove_replaced_after"` // RFC3339 + Source string `json:"source"` + Note string `json:"note"` +} + +type nsdCertInfo struct { + SubjectCN, SubjectO, IssuerCN, Serial string + NotBefore, NotAfter time.Time + SelfSigned bool +} + +type nsdBundleCert struct { + nsdManifestCert + Info nsdCertInfo + DER []byte + Origin string // bundle | dropin +} + +// asn1-структуры: разбираем только TBS-заголовок — стандартная x509 не +// понимает ГОСТ-ключи («malformed certificate»), а нам нужны только +// subject/issuer/serial/validity. Хвост SEQUENCE Go asn1 допускает. +type nsdASN1Validity struct{ NotBefore, NotAfter time.Time } +type nsdASN1TBS struct { + Version int `asn1:"optional,explicit,default:0,tag:0"` + Serial *big.Int + SigAlg asn1.RawValue + Issuer asn1.RawValue + Validity nsdASN1Validity + Subject asn1.RawValue +} +type nsdASN1Cert struct { + TBS nsdASN1TBS + SigAlg asn1.RawValue + Sig asn1.BitString +} + +func nsdName(raw asn1.RawValue) (cn, o string) { + var rdn pkix.RDNSequence + if _, err := asn1.Unmarshal(raw.FullBytes, &rdn); err != nil { + return "", "" + } + var n pkix.Name + n.FillFromRDNSequence(&rdn) + if len(n.Organization) > 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) +}