From e4de1ab4d818115e6aa6099fa75003744df16163 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 17 Sep 2026 18:26:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20=C2=AB?= =?UTF-8?q?=D0=A0=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=20M2M=C2=BB=20=D0=BF=D0=BE=20=D0=B1=D1=83=D0=BC=D0=B0=D0=B6?= =?UTF-8?q?=D0=BD=D1=8B=D0=BC=20=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC;=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=D0=B0=D1=86=D0=B8=D0=B8=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B8=D0=B7=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=81=D0=BA=D0=BE=D0=B3=D0=BE=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- internal/lkgateway/admin.go | 22 +- internal/lkgateway/auth.go | 2 +- internal/lkgateway/checks.go | 11 +- internal/lkgateway/depo_sim.go | 4 +- internal/lkgateway/export.go | 2 +- internal/lkgateway/incoming_handlers.go | 7 +- internal/lkgateway/keywizard.go | 19 +- internal/lkgateway/lkemulate.go | 14 +- internal/lkgateway/lkemulate_test.go | 2 +- internal/lkgateway/manual.go | 698 ++++++++++++++++++ internal/lkgateway/manual_handlers.go | 472 ++++++++++++ internal/lkgateway/manual_test.go | 144 ++++ internal/lkgateway/news.go | 25 + internal/lkgateway/quikdb_handlers.go | 6 +- internal/lkgateway/quikhooks.go | 2 +- internal/lkgateway/quikm2m.go | 4 +- internal/lkgateway/runtimeconfig.go | 20 +- internal/lkgateway/server.go | 6 +- internal/lkgateway/service.go | 2 +- internal/lkgateway/setup.go | 37 +- .../lkgateway/web/templates/admin_claim.html | 6 +- .../web/templates/admin_fancy_queue.html | 2 +- .../web/templates/admin_handbook.html | 2 +- .../lkgateway/web/templates/admin_help.html | 12 +- .../templates/admin_help_architecture.html | 24 +- .../templates/admin_help_fancy_events.html | 8 +- .../web/templates/admin_help_lk.html | 8 +- .../web/templates/admin_help_manual.html | 87 +++ .../web/templates/admin_help_quik.html | 4 +- .../web/templates/admin_help_systems.html | 10 +- .../lkgateway/web/templates/admin_home.html | 15 +- .../web/templates/admin_incoming.html | 6 +- .../web/templates/admin_incoming_detail.html | 18 +- .../lkgateway/web/templates/admin_manual.html | 354 +++++++++ .../lkgateway/web/templates/admin_setup.html | 49 +- .../lkgateway/web/templates/admin_status.html | 2 + .../lkgateway/web/templates/admin_wizard.html | 34 +- internal/lkgateway/web/templates/layout.html | 6 +- 38 files changed, 2014 insertions(+), 132 deletions(-) create mode 100644 internal/lkgateway/manual.go create mode 100644 internal/lkgateway/manual_handlers.go create mode 100644 internal/lkgateway/manual_test.go create mode 100644 internal/lkgateway/web/templates/admin_help_manual.html create mode 100644 internal/lkgateway/web/templates/admin_manual.html diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index ab4eec8..e670f45 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -343,8 +343,8 @@ type page struct { Title string Active string Now string - IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции - MockReason string // короткое описание почему mock + IsMockMode bool // true если ИШ или СКЗИ не настроены — обмена с НРД нет + MockReason string // короткое описание причины (у клиента — без «mock») NPR bool // модуль «НПР / УДС» включён и разрешён лицензией DevMode bool // режим разработки (сервер лицензий): видны служебные разделы } @@ -480,7 +480,7 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, page: nowPage("Дашборд", "home"), Status: status, Recent: recent.Items, - News: topNews(rc.Snapshot().News.Items, 3), + News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3), LKUrl: rc.Snapshot().LK.PublicURL, } // Готовность системы считаем ТОЛЬКО по обязательным компонентам. @@ -610,7 +610,11 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent { rej++ } } - add("Получено решение НРД (робот)", "response", + title := "Получено решение контрагента (НРД)" + if devMode(globalRC) { + title = "Получено решение НРД (робот)" + } + add(title, "response", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej), v.UpdatedAt, v.CreatedAt) } else if v.M2MResponse != nil { var codes string @@ -710,10 +714,16 @@ func nowPage(title, active string) page { switch { case s.NSD.IGWBaseURL == "": p.IsMockMode = true - p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)" + p.MockReason = "ИШ НРД не настроен — обмен с НРД не выполняется" + if p.DevMode { + p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)" + } case s.Crypto.Provider == "" || s.Crypto.Provider == "stub": p.IsMockMode = true - p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" + p.MockReason = "Не настроено средство подписи (СКЗИ) — обмен с НРД невозможен" + if p.DevMode { + p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" + } } } return p diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 977a81a..3b2bf07 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -54,7 +54,7 @@ type roleDef struct { // settings — настройка системы и пользователи; npr/quik/export — модули. var roleDefs = []roleDef{ {"admin", "Администратор", "Полный доступ: переводы, настройки, пользователи, все модули.", []string{"view", "operate", "settings", "npr", "quik", "export", "users"}}, - {"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK, тестирование M2M. Без настроек.", []string{"view", "operate", "quik"}}, + {"operator", "Оператор депозитария", "Журнал, заявки, входящие/исходящие переводы, обмен с DEPO и QUIK. Без настроек.", []string{"view", "operate", "quik"}}, {"risk", "Риск-менеджер", "Только риск-монитор НПР / УДС.", []string{"npr"}}, {"viewer", "Наблюдатель", "Просмотр журнала и переводов без права действий.", []string{"view"}}, } diff --git a/internal/lkgateway/checks.go b/internal/lkgateway/checks.go index 571bc5d..bcfacef 100644 --- a/internal/lkgateway/checks.go +++ b/internal/lkgateway/checks.go @@ -124,7 +124,11 @@ func checkCryptoSocket(o CheckOptions) Status { s.Message = "сокет открыт" s.Detail = o.CryptoSocket if o.CryptoProvider == "stub" || o.CryptoProvider == "" { - s.Message += ", провайдер stub (реальная криптография не подключена)" + if devMode(globalRC) { + s.Message += ", провайдер stub (реальная криптография не подключена)" + } else { + s.Message += ", средство подписи не настроено" + } } else { s.Message += ", провайдер " + o.CryptoProvider } @@ -136,7 +140,10 @@ func checkNSDAdapter(ctx context.Context, o CheckOptions) Status { if o.NSDAdapterURL == "" { s.OK = true s.Optional = true - s.Message = "не подключён — режим эмуляции (mock)" + s.Message = "не настроен — обмен с НРД не выполняется" + if devMode(globalRC) { + s.Message = "не подключён — режим эмуляции (mock)" + } return s } // У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state diff --git a/internal/lkgateway/depo_sim.go b/internal/lkgateway/depo_sim.go index 27713fb..1b9e291 100644 --- a/internal/lkgateway/depo_sim.go +++ b/internal/lkgateway/depo_sim.go @@ -87,8 +87,8 @@ func registerDepoSim(mux *http.ServeMux, a *admin) { 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"), + Last: v("last", "Иванов"), First: v("first", "Иван"), Middle: v("middle", "Иванович"), + Ser: v("ser", "0000"), Num: v("num", "000000"), ISIN: strings.ToUpper(v("isin", "RU0009029540")), Code: v("code", ""), Qty: v("qty", "10"), Account: v("account", defAcc), Section: v("section", defSec), } diff --git a/internal/lkgateway/export.go b/internal/lkgateway/export.go index e18cab8..a615bbf 100644 --- a/internal/lkgateway/export.go +++ b/internal/lkgateway/export.go @@ -269,7 +269,7 @@ func compareNote(va, vb ClaimView, identical bool) []byte { b.WriteString("response.xml/decision.xml (ответ/решение НРД), а также отчёт otchet.html.\n") b.WriteString("Все XML — в исходной кодировке windows-1251, дословно.\n\n") b.WriteString("Просьба: пояснить причину изменения результата и порядок устранения\n") - b.WriteString("(в т.ч. если это связано с учётом тестовых ценных бумаг на стороне НРД).\n") + b.WriteString("(в т.ч. если это связано с особенностями учёта ценных бумаг на стороне НРД).\n") return []byte(b.String()) } diff --git a/internal/lkgateway/incoming_handlers.go b/internal/lkgateway/incoming_handlers.go index fe89f5f..d9a0d48 100644 --- a/internal/lkgateway/incoming_handlers.go +++ b/internal/lkgateway/incoming_handlers.go @@ -219,7 +219,10 @@ func handleIncomingAnswer(w http.ResponseWriter, r *http.Request, deps incomingD } decisions = append(decisions, d) } - msg := "Решение отправлено роботу" + msg := "Решение отправлено контрагенту через НРД" + if devMode(deps.rc) { + msg = "Решение отправлено роботу" + } if err := answerIncoming(r.Context(), deps, id, decisions); err != nil { msg = "Ошибка отправки решения: " + err.Error() } @@ -235,7 +238,7 @@ type incomingDetailData struct { OrderDone bool OrderDoneAt string DraftReceived string - Depo depoResult // сверка с DEPO — эмуляция ответа, ничего не отправляется + Depo depoResult // сверка с DEPO — рекомендация по ответу, ничего не отправляется Quik []*QuikLink QuikOn bool Watch *OrderWatch diff --git a/internal/lkgateway/keywizard.go b/internal/lkgateway/keywizard.go index 9d19c6f..49ce084 100644 --- a/internal/lkgateway/keywizard.go +++ b/internal/lkgateway/keywizard.go @@ -65,12 +65,16 @@ func (s *keyWizardState) reset() { } func defaultKeySteps() []keyWizardStep { + last := "Готово — можно проверить подключения" + if devMode(globalRC) { + last = "Готово — можно отправлять тестовый документ" + } return []keyWizardStep{ {Title: "Загрузка архива и распаковка", Status: "pending"}, {Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"}, {Title: "Формирование справочника сертификатов (CRL)", Status: "pending"}, {Title: "Перезапуск и проверка ИШ", Status: "pending"}, - {Title: "Готово — можно отправлять тестовый документ", Status: "pending"}, + {Title: last, Status: "pending"}, } } @@ -356,14 +360,23 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) return } h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg) - h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже") + dev := devMode(h.rc) + if dev { + h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже") + } else { + h.keyWiz.set(4, "ok", "Ключ установлен — проверьте подключения на странице «Статус»") + } h.keyWiz.Done = true h.keyWiz.mu.Unlock() // Ключ уже на флешке и в ИШ — распаковка staging больше не нужна. _ = os.RemoveAll(staging) - http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther) + final := "Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает." + if dev { + final += " Финальная проверка — тестовым документом роботу." + } + http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(final), http.StatusSeeOther) } // restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он diff --git a/internal/lkgateway/lkemulate.go b/internal/lkgateway/lkemulate.go index dd2bc0e..d901efc 100644 --- a/internal/lkgateway/lkemulate.go +++ b/internal/lkgateway/lkemulate.go @@ -39,7 +39,7 @@ func lkEmulateNSD(rc *RuntimeConfig) (bool, string) { return true, "включена в настройках" } if s.LK.ActiveEnv == "prod" && strings.HasPrefix(strings.ToLower(s.NSD.Profile), "test") { - return true, "боевой ЛК при тестовом профиле ИШ " + s.NSD.Profile + return true, "боевой ЛК при тестовом контуре НРД (профиль ИШ " + s.NSD.Profile + ")" } return false, "" } @@ -106,7 +106,11 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep lkMarkSent(id) lkLinkGUID(guid, ep, id) globalLKFlows.update(guid, func(f *LKFlow) { f.Stage = "sent" }) - globalLKFlows.step(guid, id, "gateway", "Бумаги заблокированы, заявка передана имитатору НРД", + sentTitle := "Бумаги заблокированы, заявка обработана шлюзом bj без отправки в НРД" + if devMode(rc) { + sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД" + } + globalLKFlows.step(guid, id, "gateway", sentTitle, 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) @@ -233,7 +237,11 @@ func (s *Server) consumeEmulatedDecisions(ctx context.Context) { rej++ } } - globalLKFlows.stepIfExists(guid, "response", "Решение контрагента (имитатор НРД)", fmt.Sprintf("Принято %d, отклонено %d.", conf, rej)) + respTitle := "Решение по заявке (без отправки в НРД)" + if devMode(s.rc) { + respTitle = "Решение контрагента (имитатор НРД)" + } + globalLKFlows.stepIfExists(guid, "response", respTitle, fmt.Sprintf("Принято %d, отклонено %d.", conf, rej)) s.applyDecisionEffects(ctx, d, "имитатор НРД", lkEmuNote) } } diff --git a/internal/lkgateway/lkemulate_test.go b/internal/lkgateway/lkemulate_test.go index 1c8678c..8fb899a 100644 --- a/internal/lkgateway/lkemulate_test.go +++ b/internal/lkgateway/lkemulate_test.go @@ -45,7 +45,7 @@ func TestLKEmulateNSD(t *testing.T) { if err := rc.UpdateNSD(s.NSD); err != nil { t.Fatal(err) } - if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом профиле") { + if on, why := lkEmulateNSD(rc); !on || !strings.Contains(why, "тестовом контуре НРД") { t.Errorf("боевой ЛК + тестовый ИШ: ожидали имитацию, получили %v %q", on, why) } s.LK.ActiveEnv = "test" diff --git a/internal/lkgateway/manual.go b/internal/lkgateway/manual.go new file mode 100644 index 0000000..635d79b --- /dev/null +++ b/internal/lkgateway/manual.go @@ -0,0 +1,698 @@ +package lkgateway + +// manual.go — ручной ввод ИСХОДЯЩЕГО перевода M2M по бумажному поручению клиента. +// +// Клиент приносит поручение на бумаге, сотрудник депозитария вводит его в bj так, как клиент +// заполнил бы заявку в личном кабинете. Дальше перевод идёт тем же путём, что и заявка ЛК: +// пакет M2MTransferRequest в НРД → запись в DEPO (sentHook) → блокировка в QUIK (quikSendHook, +// если сотрудник не заблокировал вручную) → ответ контрагента → черновик формы 16 → поручение 16 +// (вручную кнопкой или автоматическим режимом) → отчёт НРД MS010. +// +// Отправить нельзя, пока не заполнены реквизиты, не выполнена сверка с DEPO (если учётная система +// подключена) и не проставлены обязательные отметки сверки с учётной и торговой системами. Каждая +// отметка хранит, кто и когда её поставил. Поручения — в manual-orders.json. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "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" +) + +const manualFile = "/var/lib/bj/.bj/manual-orders.json" + +// ManualSec — бумага в поручении. +type ManualSec struct { + ISIN string `json:"isin"` + Code string `json:"code,omitempty"` // код НРД (необязательно; пусто — ISIN) + Name string `json:"name,omitempty"` + Qty string `json:"qty"` +} + +// ManualMark — отметка сотрудника (кто и когда). +type ManualMark struct { + Done bool `json:"done"` + By string `json:"by,omitempty"` + At time.Time `json:"at,omitempty"` +} + +// ManualDepoCheck — результат сверки с DEPO на момент Hash (данные поручения). +type ManualDepoCheck struct { + At time.Time `json:"at"` + By string `json:"by,omitempty"` + Hash string `json:"hash"` + Ran bool `json:"ran"` + Err string `json:"err,omitempty"` + Recommend string `json:"recommend,omitempty"` + Summary string `json:"summary,omitempty"` + Investor string `json:"investor,omitempty"` + Account string `json:"account,omitempty"` + Secs []depoSecResult `json:"secs,omitempty"` +} + +// ManualOrder — поручение, введённое вручную. +type ManualOrder struct { + ID int `json:"id"` + CreatedAt time.Time `json:"created_at"` + CreatedBy string `json:"created_by,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + + // Основание + PaperNo string `json:"paper_no"` + PaperDate string `json:"paper_date"` // ГГГГ-ММ-ДД + ReceivedVia string `json:"received_via"` + + // Клиент + LastName string `json:"last_name"` + FirstName string `json:"first_name"` + MiddleName string `json:"middle_name,omitempty"` + BirthDate string `json:"birth_date"` // ГГГГ-ММ-ДД + DocType string `json:"doc_type"` + DocSeries string `json:"doc_series,omitempty"` + DocNumber string `json:"doc_number"` + DocIssueDate string `json:"doc_issue_date,omitempty"` + DocIssuer string `json:"doc_issuer,omitempty"` + Contract string `json:"contract,omitempty"` + Account string `json:"account"` + QuikCode string `json:"quik_code,omitempty"` + Phone string `json:"phone,omitempty"` + + // Принимающая сторона + RecvINN string `json:"recv_inn"` + RecvCode string `json:"recv_code,omitempty"` + RecvName string `json:"recv_name,omitempty"` + RecvAccount string `json:"recv_account,omitempty"` // счёт клиента у принимающего (для сведения) + SettlePlace string `json:"settle_place"` // nrd | spb + CostInfo bool `json:"cost_info"` + + Securities []ManualSec `json:"securities"` + Comment string `json:"comment,omitempty"` + + // Сверка и отметки до отправки + Depo *ManualDepoCheck `json:"depo,omitempty"` + Checks map[string]ManualMark `json:"checks,omitempty"` + QuikMode string `json:"quik_mode,omitempty"` // auto | manual | none + QuikInstr string `json:"quik_instr,omitempty"` + + // Отправка + Status string `json:"status"` // draft | sent | declined + GUID string `json:"guid,omitempty"` + DealID string `json:"deal_id,omitempty"` + SentAt time.Time `json:"sent_at,omitempty"` + SentBy string `json:"sent_by,omitempty"` + SendErr string `json:"send_err,omitempty"` + DeclineReason string `json:"decline_reason,omitempty"` + DeclinedBy string `json:"declined_by,omitempty"` + DeclinedAt time.Time `json:"declined_at,omitempty"` + + // Отметки после ответа + Post map[string]ManualMark `json:"post,omitempty"` +} + +// FullName — ФИО клиента. +func (o *ManualOrder) FullName() string { + return strings.TrimSpace(strings.Join([]string{o.LastName, o.FirstName, o.MiddleName}, " ")) +} + +// Number — номер поручения в bj. +func (o *ManualOrder) Number() string { return fmt.Sprintf("Р-%05d", o.ID) } + +// dataHash — отпечаток данных, от которых зависят сверка и отметки: изменили данные — сверку и +// отметки нужно пройти заново. +func (o *ManualOrder) dataHash() string { + b, _ := json.Marshal([]any{o.LastName, o.FirstName, o.MiddleName, o.BirthDate, o.DocType, o.DocSeries, o.DocNumber, + o.Account, o.RecvINN, o.SettlePlace, o.CostInfo, o.Securities}) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:8]) +} + +// manualCheckDef — обязательная отметка. +type manualCheckDef struct{ Key, Group, Title, Hint string } + +// manualPreChecks — отметки перед отправкой (все обязательны). +var manualPreChecks = []manualCheckDef{ + {"paper", "Поручение клиента", "Поручение оформлено правильно", + "подпись клиента или представителя, дата, реквизиты бумаг и принимающего депозитария; полномочия представителя (доверенность) проверены"}, + {"client", "Учётная система депозитария", "Клиент идентифицирован", + "ФИО, документ, удостоверяющий личность, и дата рождения совпадают с анкетой депонента"}, + {"account", "Учётная система депозитария", "Счёт депо открыт, принадлежит клиенту и не является ИИС", + "перевод с индивидуального инвестиционного счёта через M2M не выполняется"}, + {"balance", "Учётная система депозитария", "Остаток достаточен, бумаги свободны", + "на торговом разделе не меньше количества в поручении; нет обременений, ареста и блокировок"}, + {"security", "Учётная система депозитария", "Бумаги допускаются к переводу M2M", + "российские выпуски (ISIN начинается с RU), приняты на обслуживание"}, + {"qual", "Учётная система депозитария", "Ограничения для квалифицированных инвесторов проверены", + "если бумага только для квалифицированных инвесторов — статус клиента проверен, клиент предупреждён о возможном отказе"}, + {"fees", "Учётная система депозитария", "Задолженности по оплате услуг нет", + "или задолженность урегулирована по правилам депозитария"}, + {"counterparty", "Принимающая сторона", "Принимающий депозитарий — участник M2M", + "ИНН и наименование сверены со справочником участников НРД"}, +} + +// manualPostDef — отметки после ответа: when — done (исполнено) | rejected (отказ); quik — только при +// ручной блокировке в торговой системе. +type manualPostDef struct { + Key, When, Title string + Quik bool +} + +var manualPostChecks = []manualPostDef{ + {"quik_debit", "done", "Списание в торговой системе проведено (лимит уменьшен окончательно)", true}, + {"depo_done", "done", "Операция списания отражена в учётной системе депозитария", false}, + {"client_done", "done", "Клиент уведомлён об исполнении", false}, + {"paper_done", "done", "На поручении проставлена отметка об исполнении", false}, + {"quik_unblock", "rejected", "Блокировка в торговой системе снята (лимит восстановлен)", true}, + {"client_rejected", "rejected", "Клиент уведомлён об отказе и его причине", false}, + {"paper_rejected", "rejected", "На поручении проставлена отметка об отказе", false}, +} + +// manualStore — поручения ручного ввода (переживают перезапуск). +type manualStore struct { + mu sync.Mutex + path string + items map[int]*ManualOrder + next int +} + +var globalManual = loadManualStore(manualFile) + +func loadManualStore(path string) *manualStore { + s := &manualStore{path: path, items: map[int]*ManualOrder{}, next: 1} + if b, err := os.ReadFile(path); err == nil { + var list []*ManualOrder + if json.Unmarshal(b, &list) == nil { + for _, o := range list { + if o.Status == "sending" { // отправка прервана перезапуском + o.Status, o.SendErr = "draft", "отправка прервана перезапуском сервиса — перед повтором проверьте журнал исходящих переводов" + } + s.items[o.ID] = o + if o.ID >= s.next { + s.next = o.ID + 1 + } + } + } + } + return s +} + +func (s *manualStore) saveLocked() error { + list := make([]*ManualOrder, 0, len(s.items)) + for _, o := range s.items { + list = append(list, o) + } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + b, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func (s *manualStore) Get(id int) (*ManualOrder, bool) { + s.mu.Lock() + defer s.mu.Unlock() + o, ok := s.items[id] + if !ok { + return nil, false + } + b, _ := json.Marshal(o) // глубокая копия + var cp ManualOrder + _ = json.Unmarshal(b, &cp) + return &cp, true +} + +func (s *manualStore) List() []*ManualOrder { + s.mu.Lock() + ids := make([]int, 0, len(s.items)) + for id := range s.items { + ids = append(ids, id) + } + s.mu.Unlock() + sort.Sort(sort.Reverse(sort.IntSlice(ids))) + out := make([]*ManualOrder, 0, len(ids)) + for _, id := range ids { + if o, ok := s.Get(id); ok { + out = append(out, o) + } + } + return out +} + +// Create — новое поручение (ID присваивается). +func (s *manualStore) Create(o *ManualOrder) error { + s.mu.Lock() + defer s.mu.Unlock() + o.ID = s.next + s.next++ + o.CreatedAt, o.UpdatedAt = time.Now(), time.Now() + s.items[o.ID] = o + return s.saveLocked() +} + +// Update — изменение под замком: fn получает текущую запись. +func (s *manualStore) Update(id int, fn func(o *ManualOrder) error) error { + s.mu.Lock() + defer s.mu.Unlock() + o, ok := s.items[id] + if !ok { + return errors.New("поручение не найдено") + } + b, _ := json.Marshal(o) + var cp ManualOrder + _ = json.Unmarshal(b, &cp) + if err := fn(&cp); err != nil { + return err + } + cp.UpdatedAt = time.Now() + s.items[id] = &cp + return s.saveLocked() +} + +var ( + manualINNRe = regexp.MustCompile(`^\d{10}$`) + manualISINRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}\d$`) + manualDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) +) + +// manualQty — количество: целое или дробное (паи), > 0. +func manualQty(s string) (Quantity, bool) { + s = strings.ReplaceAll(strings.TrimSpace(s), ",", ".") + f, err := strconv.ParseFloat(s, 64) + if err != nil || f <= 0 { + return Quantity{}, false + } + if f == float64(uint64(f)) { + return Quantity{Whole: uint64(f)}, true + } + return Quantity{Fractional: strconv.FormatFloat(f, 'f', -1, 64)}, true +} + +// manualDataProblems — что не заполнено или заполнено неверно. +func manualDataProblems(o *ManualOrder) []string { + var p []string + need := func(v, what string) { + if strings.TrimSpace(v) == "" { + p = append(p, "не заполнено: "+what) + } + } + need(o.PaperNo, "номер поручения клиента") + if !manualDateRe.MatchString(o.PaperDate) { + p = append(p, "не заполнена дата поручения клиента") + } + need(o.LastName, "фамилия клиента") + need(o.FirstName, "имя клиента") + if !manualDateRe.MatchString(o.BirthDate) { + p = append(p, "не заполнена дата рождения клиента") + } + need(o.DocType, "вид документа") + need(o.DocNumber, "номер документа") + if o.DocType == "21" && len(strings.ReplaceAll(o.DocSeries, " ", "")) != 4 { + p = append(p, "серия паспорта РФ — 4 цифры") + } + need(o.Account, "счёт депо клиента") + if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) { + p = append(p, "ИНН принимающего депозитария — 10 цифр") + } + if len(o.Securities) == 0 { + p = append(p, "нет ни одной бумаги") + } + for i, s := range o.Securities { + isin := strings.ToUpper(strings.TrimSpace(s.ISIN)) + switch { + case !manualISINRe.MatchString(isin): + p = append(p, fmt.Sprintf("бумага %d: неверный ISIN «%s»", i+1, s.ISIN)) + case !isRussianISIN(isin): + p = append(p, fmt.Sprintf("бумага %d: %s — иностранный выпуск, через M2M не переводится", i+1, isin)) + } + if _, ok := manualQty(s.Qty); !ok { + p = append(p, fmt.Sprintf("бумага %d: количество должно быть больше нуля", i+1)) + } + } + return p +} + +// manualDepoConfigured — подключена ли учётная система (сверка обязательна). +func manualDepoConfigured(rc *RuntimeConfig) bool { + return rc != nil && strings.TrimSpace(rc.Snapshot().Fancy.Server) != "" +} + +// manualQuikAutoAvailable — может ли bj сам заблокировать бумаги в торговой системе при отправке. +func manualQuikAutoAvailable(rc *RuntimeConfig) bool { + if rc == nil { + return false + } + q := rc.Snapshot().Quik + return quikActive(q) && q.AutoOut +} + +// manualSendBlockers — почему поручение пока нельзя отправить (пусто — можно). +func manualSendBlockers(rc *RuntimeConfig, o *ManualOrder) []string { + if o.Status != "draft" { + return []string{"поручение уже " + manualStatusRu(o.Status)} + } + b := manualDataProblems(o) + if manualDepoConfigured(rc) { + switch { + case o.Depo == nil: + b = append(b, "не выполнена сверка с учётной системой (DEPO)") + case o.Depo.Hash != o.dataHash(): + b = append(b, "данные изменены после сверки с DEPO — выполните сверку заново") + case !o.Depo.Ran: + b = append(b, "сверка с DEPO не выполнена: "+o.Depo.Err) + case o.Depo.Recommend != "confirm": + b = append(b, "сверка с DEPO не пройдена: "+o.Depo.Summary) + } + } + for _, c := range manualPreChecks { + if !o.Checks[c.Key].Done { + b = append(b, "нет отметки: "+c.Title) + } + } + switch o.QuikMode { + case "auto": + if !manualQuikAutoAvailable(rc) { + b = append(b, "автоматическая блокировка в торговой системе не включена — заблокируйте бумаги вручную") + } + case "manual": + if strings.TrimSpace(o.QuikInstr) == "" { + b = append(b, "укажите номер поручения на блокировку в торговой системе") + } + case "none": + default: + b = append(b, "не выбрано, как заблокированы бумаги в торговой системе") + } + return b +} + +func manualStatusRu(s string) string { + switch s { + case "draft": + return "черновик" + case "sent": + return "отправлено в НРД" + case "sending": + return "отправляется" + case "declined": + return "отклонено депозитарием" + } + return s +} + +// manualClaim — заявка в том же виде, что из личного кабинета. +func manualClaim(rc *RuntimeConfig, o *ManualOrder) (CreateClaimRequest, error) { + nsd := rc.Snapshot().NSD + var secs []ClaimSec + for _, s := range o.Securities { + isin := strings.ToUpper(strings.TrimSpace(s.ISIN)) + q, ok := manualQty(s.Qty) + if !ok { + return CreateClaimRequest{}, fmt.Errorf("количество по %s", isin) + } + secs = append(secs, ClaimSec{ + SecurityCode: pick(strings.TrimSpace(s.Code), isin), + SecurityDetails: SecurityDetails{ISIN: isin}, + Quantity: q, + SettlementAccounts: []SettlementAccount{{ + SettlementRequisitesINN: transferringINN(rc.Snapshot()), + SettlementLocation: SettlementLocation{DeponentCode: nsd.DeponentCode, AccountID: nsd.AccountID, SectionID: nsd.SectionID}, + }}, + }) + } + cost := CostInfo{No: &struct{}{}} + if o.CostInfo { + cost = CostInfo{Yes: &CostInfoYes{Code: nsd.DeponentCode}} + } + ser := strings.ReplaceAll(strings.TrimSpace(o.DocSeries), " ", "") + return CreateClaimRequest{ + Investor: Investor{ + ID: lkInvestorUUID("manual:" + ser + strings.TrimSpace(o.DocNumber)), + LastName: strings.TrimSpace(o.LastName), FirstName: strings.TrimSpace(o.FirstName), MiddleName: strings.TrimSpace(o.MiddleName), + Document: Document{DocumentType: o.DocType, Series: ser, Number: strings.TrimSpace(o.DocNumber)}, + BirthDate: o.BirthDate, + }, + TransferringDepositoryINN: transferringINN(rc.Snapshot()), + ReceivingDepositoryINN: strings.TrimSpace(o.RecvINN), + CostInfo: cost, + Securities: secs, + SignatureFormat: "PAPER", + ClientAccount: strings.ToUpper(strings.TrimSpace(o.Account)), + }, nil +} + +// manualDepoRun — сверка поручения с DEPO теми же проверками, что и заявка ЛК. +func manualDepoRun(rc *RuntimeConfig, o *ManualOrder) *ManualDepoCheck { + nsd := rc.Snapshot().NSD + var secs []depoOutSec + for i, s := range o.Securities { + secs = append(secs, depoOutSec{ReferenceID: strconv.Itoa(i + 1), ISIN: strings.ToUpper(strings.TrimSpace(s.ISIN)), Code: s.Code, Qty: s.Qty}) + } + res := runDepoCheckOutAccount(o.LastName, o.FirstName, o.MiddleName, strings.ReplaceAll(o.DocSeries, " ", ""), strings.TrimSpace(o.DocNumber), o.BirthDate, + secs, nsd.AccountID, nsd.SectionID, strings.ToUpper(strings.TrimSpace(o.Account))) + return &ManualDepoCheck{At: time.Now(), Hash: o.dataHash(), Ran: res.Ran, Err: res.Err, Recommend: res.Recommend, + Summary: res.Summary, Investor: res.Investor, Account: res.Account, Secs: res.Secs} +} + +// manualDepoClient — клиент по документу из DEPO: ФИО, дата рождения, открытый торговый счёт. +type manualDepoClient struct { + Found bool + Name, Birth, Account string + Err string +} + +func manualDepoLookup(rc *RuntimeConfig, ser, num string) manualDepoClient { + var out manualDepoClient + if !manualDepoConfigured(rc) { + out.Err = "учётная система (DEPO) не подключена" + return out + } + fs := rc.Snapshot().Fancy + db, ctx, cancel, errS := depoOpen(fs) + if errS != "" { + out.Err = errS + return out + } + defer cancel() + defer db.Close() + p := depoResolveParty(ctx, db, fs, strings.ReplaceAll(ser, " ", ""), strings.TrimSpace(num), "") + if !p.faceOK { + out.Err = "клиент с таким документом в DEPO не найден" + return out + } + out.Found, out.Name, out.Account = true, p.faceName, p.accOpen + if p.birth.Valid { + out.Birth = p.birth.Time.Format("2006-01-02") + } + return out +} + +// manualDepoSecNames — наименования бумаг из DEPO по ISIN (не найденные — без записи). +func manualDepoSecNames(rc *RuntimeConfig, isins []string) map[string]string { + out := map[string]string{} + if !manualDepoConfigured(rc) || len(isins) == 0 { + return out + } + fs := rc.Snapshot().Fancy + db, ctx, cancel, errS := depoOpen(fs) + if errS != "" { + return out + } + defer cancel() + defer db.Close() + for _, isin := range isins { + if _, name, found, _ := depoSecurity(ctx, db, isin); found { + out[isin] = name + } + } + return out +} + +// manualQuikSkipKey — в контексте отправки: бумаги в торговой системе заблокированы вручную +// (или торговая система не используется) — хук QUIK не вызывается. +type manualQuikSkipKey struct{} + +func manualQuikSkipped(ctx context.Context) bool { + v, _ := ctx.Value(manualQuikSkipKey{}).(bool) + return v +} + +// manualSend — отправка поручения в НРД тем же путём, что и заявка из личного кабинета. +func manualSend(ctx context.Context, rc *RuntimeConfig, svc *Service, o *ManualOrder) (guid, dealID string, err error) { + claim, err := manualClaim(rc, o) + if err != nil { + return "", "", err + } + if ss, ok := svc.store.(*SeedStore); ok { + nsd := rc.Snapshot().NSD + ss.EnsureLKClient(LKUser{ + Login: "manual:" + o.Number(), Contract: claim.Investor.ID, + LastName: claim.Investor.LastName, FirstName: claim.Investor.FirstName, MiddleName: claim.Investor.MiddleName, + DocType: claim.Investor.Document.DocumentType, DocSeries: claim.Investor.Document.Series, DocNumber: claim.Investor.Document.Number, + Active: true, + }, nil, m2mcore.DepoAccount{ + DeponentCode: nsd.DeponentCode, + AccountID: m2m.AccountID(nsd.AccountID), + SectionID: nsd.SectionID, + DepositoryINN: m2m.OrganizationINN(settlementPlaceINN(o.SettlePlace)), + }) + } + req, err := svc.BuildRequest(ctx, claim) + if err != nil { + return "", "", fmt.Errorf("пакет не прошёл проверку формата НРД: %w", err) + } + sender := svc.sender + if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil { + sender = lkEmuSender + } + if o.QuikMode != "auto" { + ctx = context.WithValue(ctx, manualQuikSkipKey{}, true) + } + resp, err := svc.SendBuiltRequestVia(ctx, sender, req, claim.Investor.ID, nil) + if err != nil { + return string(req.Header.GUID), "", err + } + return string(req.Header.GUID), resp.ID, nil +} + +// ---- ход исполнения (для карточки) ---- + +// manualStep — шаг исполнения: State — done | wait | todo | fail | skip. +type manualStep struct { + Title, Detail, State string + At time.Time +} + +// manualProgress — ход исполнения отправленного поручения и итог: outcome — "" (в работе) | +// done | rejected. +type manualProgress struct { + Steps []manualStep + Outcome string + Claim *ClaimView + Depo *OutgoingDeal + Quik []*QuikLink +} + +func manualProgressFor(ctx context.Context, svc *Service, o *ManualOrder) manualProgress { + var pr manualProgress + add := func(title, detail, state string, at time.Time) { + pr.Steps = append(pr.Steps, manualStep{Title: title, Detail: detail, State: state, At: at}) + } + add("Поручение введено", o.CreatedBy, "done", o.CreatedAt) + if o.Depo != nil { + st := "done" + if !o.Depo.Ran || o.Depo.Recommend != "confirm" { + st = "fail" + } + add("Сверка с учётной системой", pick(o.Depo.Summary, o.Depo.Err), st, o.Depo.At) + } + if o.Status == "declined" { + add("Отклонено депозитарием", o.DeclineReason, "fail", o.DeclinedAt) + pr.Outcome = "rejected" + return pr + } + if o.Status != "sent" { + add("Отправка в НРД", "после сверки и отметок", "todo", time.Time{}) + return pr + } + add("Пакет отправлен в НРД", "GUID "+o.GUID, "done", o.SentAt) + if svc != nil && o.DealID != "" { + if v, err := svc.GetClaim(ctx, o.DealID); err == nil { + pr.Claim = &v + } + } + if o.GUID != "" { + pr.Quik = globalQuikLinks.ForGUID(o.GUID) + if globalOutgoingStore != nil { + if d, ok := scanForm16Deal(globalOutgoingStore, o.GUID); ok { + pr.Depo = d + } + } + } + c := pr.Claim + switch { + case c == nil: + add("Ответ сервиса MOEX МОСТ", "нет данных о переводе", "wait", time.Time{}) + case c.M2MResponse != nil && c.M2MResponse.StatusCode == "ERROR": + var codes []string + for _, r := range c.M2MResponse.Responses { + codes = append(codes, strings.TrimSpace(r.Code+" "+r.Text)) + } + add("Ответ сервиса MOEX МОСТ: ошибка", strings.Join(codes, "; "), "fail", c.UpdatedAt) + pr.Outcome = "rejected" + return pr + case c.M2MResponse != nil: + add("Пакет принят сервисом MOEX МОСТ", c.M2MResponse.StatusCode, "done", time.Time{}) + default: + add("Ответ сервиса MOEX МОСТ", "ждём квитанцию", "wait", time.Time{}) + } + if c != nil && c.M2MDecision != nil { + rejected := false + var codes []string + for _, s := range c.M2MDecision.Securities { + if s.Outcome == "rejected" { + rejected = true + codes = append(codes, s.RejectCodes...) + } + } + if rejected { + add("Принимающий депозитарий отказал", rejectCodesLabel(codes), "fail", time.Time{}) + pr.Outcome = "rejected" + return pr + } + add("Принимающий депозитарий согласился", "", "done", time.Time{}) + } else if c != nil && (c.Status == "rejected" || c.Status == "timed_out") { + add("Перевод не состоялся", russianState(c.Status), "fail", c.UpdatedAt) + pr.Outcome = "rejected" + return pr + } else if pr.Depo == nil { + add("Решение принимающего депозитария", "ждём ответ контрагента", "wait", time.Time{}) + } + d := pr.Depo + switch { + case d == nil: + add("Черновик поручения 16 от НРД", "придёт после согласия контрагента", "todo", time.Time{}) + add("Поручение 16 подано", "", "todo", time.Time{}) + add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{}) + return pr + case !d.Submitted: + add("Черновик поручения 16 получен", "", "done", d.DraftAt) + add("Поручение 16 подано", "подайте поручение кнопкой ниже (в автоматическом режиме bj подаёт сам)", "wait", time.Time{}) + add("Отчёт НРД об исполнении (MS010)", "", "todo", time.Time{}) + return pr + } + add("Черновик поручения 16 получен", "", "done", d.DraftAt) + add("Поручение 16 подано", "№ "+strings.Join(d.ContrDIDs, ", "), "done", d.OrderAt) + if d.Done { + add("Отчёт НРД об исполнении (MS010)", "бумаги списаны", "done", time.Time{}) + pr.Outcome = "done" + } else { + add("Отчёт НРД об исполнении (MS010)", "ждём встречное поручение контрагента и отчёт НРД", "wait", time.Time{}) + } + return pr +} + +// manualDocTypes — виды документов (справочник НРД). +var manualDocTypes = []struct{ Code, Title string }{ + {"21", "Паспорт гражданина РФ"}, + {"03", "Свидетельство о рождении"}, + {"10", "Паспорт иностранного гражданина"}, + {"12", "Вид на жительство в РФ"}, + {"22", "Заграничный паспорт гражданина РФ"}, + {"07", "Военный билет"}, + {"91", "Иной документ"}, +} diff --git a/internal/lkgateway/manual_handlers.go b/internal/lkgateway/manual_handlers.go new file mode 100644 index 0000000..3028e43 --- /dev/null +++ b/internal/lkgateway/manual_handlers.go @@ -0,0 +1,472 @@ +package lkgateway + +// manual_handlers.go — страницы раздела «Ручной ввод M2M» (/admin/manual). + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type manualPageData struct { + page + Mode string // list | edit | card | print + Flash string + List []manualListRow + O *ManualOrder + Handbook []HandbookEntry + DocTypes []struct{ Code, Title string } + PreChecks []manualCheckDef + PostChecks []manualPostDef + Problems []string // незаполненные реквизиты (для формы) + Blockers []string // почему нельзя отправить + DepoOn bool // учётная система подключена — сверка обязательна + DepoStale bool // данные изменены после сверки + QuikAuto bool // bj может сам заблокировать бумаги в торговой системе + Progress manualProgress + NSD NSDSettings + Auto bool // автоматический режим M2M включён (поручение 16 подаётся само) +} + +// manualListRow — строка списка. +type manualListRow struct { + O *ManualOrder + State string // draft | sent | declined | done | rejected + StateRu string +} + +func manualActor(w http.ResponseWriter) string { + if u := currentUser(w); u != nil { + if u.Name != "" { + return u.Name + " (" + u.Login + ")" + } + return u.Login + } + return "без входа" +} + +func manualHandbook() []HandbookEntry { + raw, err := os.ReadFile(handbookFile) + if err != nil { + return nil + } + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + if json.Unmarshal(raw, &hb) != nil { + return nil + } + var out []HandbookEntry + for _, p := range hb.Participants { + if p.Kind != "place" && p.INN != "" { + out = append(out, p) + } + } + return out +} + +func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { + tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, + "web/templates/layout.html", "web/templates/admin_manual.html") + if err != nil { + log.Fatalf("lk-gateway: шаблон admin_manual: %v", err) + } + help, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, + "web/templates/layout.html", "web/templates/admin_help_manual.html") + if err != nil { + log.Fatalf("lk-gateway: шаблон admin_help_manual: %v", err) + } + mux.HandleFunc("/admin/help/manual", func(w http.ResponseWriter, r *http.Request) { + render(w, help, nowPage("Ручной ввод M2M: инструкция", "help")) + }) + base := func(title string) manualPageData { + return manualPageData{ + page: nowPage(title, "manual"), DocTypes: manualDocTypes, PreChecks: manualPreChecks, PostChecks: manualPostChecks, + DepoOn: manualDepoConfigured(rc), QuikAuto: manualQuikAutoAvailable(rc), NSD: rc.Snapshot().NSD, + } + } + back := func(w http.ResponseWriter, r *http.Request, to, msg, anchor string) { + if msg != "" { + to += "?flash=" + url.QueryEscape(msg) + } + http.Redirect(w, r, to+anchor, http.StatusSeeOther) + } + + mux.HandleFunc("/admin/manual", func(w http.ResponseWriter, r *http.Request) { + d := base("Ручной ввод M2M") + d.Mode, d.Flash = "list", r.URL.Query().Get("flash") + var drafts map[string]*OutgoingDeal + for _, o := range globalManual.List() { + row := manualListRow{O: o, State: o.Status, StateRu: manualStatusRu(o.Status)} + if o.Status == "sent" { + if drafts == nil { // INBOX читаем один раз на список + drafts = map[string]*OutgoingDeal{} + for _, dd := range scanForm16Drafts(globalOutgoingStore) { + drafts[dd.GUID] = dd + } + } + row.StateRu = "в работе" + if dd := drafts[o.GUID]; dd != nil && dd.Done { + row.State, row.StateRu = "done", "исполнено" + } else if o.DealID != "" && svc != nil { + if v, err := svc.GetClaim(r.Context(), o.DealID); err == nil && (v.Status == "rejected" || v.Status == "timed_out") { + row.State, row.StateRu = "rejected", "отказ" + } + } + } + d.List = append(d.List, row) + } + render(w, tpl, d) + }) + + mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) { + d := base("Новое поручение") + d.Mode = "edit" + d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"), + ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}} + d.Handbook = manualHandbook() + render(w, tpl, d) + }) + + // Сохранение формы (новое или черновик). do: save | lookup (заполнить из DEPO) | depo (сверка). + mux.HandleFunc("/admin/manual/save", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + _ = r.ParseForm() + in := manualFromForm(r) + id, _ := strconv.Atoi(r.FormValue("id")) + actor := manualActor(w) + msg := "Поручение сохранено" + if id == 0 { + in.Status, in.CreatedBy = "draft", actor + if err := globalManual.Create(in); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + id = in.ID + } else { + reset := false + err := globalManual.Update(id, func(o *ManualOrder) error { + if o.Status != "draft" { + return fmt.Errorf("поручение уже %s — изменить нельзя", manualStatusRu(o.Status)) + } + old := o.dataHash() + manualCopyData(o, in) + if o.dataHash() != old && (o.Depo != nil || len(o.Checks) > 0) { + o.Depo, o.Checks, reset = nil, nil, true + } + return nil + }) + if err != nil { + back(w, r, fmt.Sprintf("/admin/manual/%d", id), err.Error(), "") + return + } + if reset { + msg = "Данные изменены — сверка и отметки сброшены, пройдите их заново" + } + } + switch r.FormValue("do") { + case "lookup": + msg = manualDoLookup(rc, id) + case "depo": + msg = manualDoDepo(rc, id, actor) + } + anchor := "" + if r.FormValue("do") == "depo" { + anchor = "#depo" + } + back(w, r, fmt.Sprintf("/admin/manual/%d", id), msg, anchor) + }) + + mux.HandleFunc("/admin/manual/", func(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/admin/manual/") + parts := strings.SplitN(rest, "/", 2) + id, err := strconv.Atoi(parts[0]) + if err != nil { + http.NotFound(w, r) + return + } + action := "" + if len(parts) == 2 { + action = parts[1] + } + o, ok := globalManual.Get(id) + if !ok { + http.NotFound(w, r) + return + } + card := fmt.Sprintf("/admin/manual/%d", id) + if action != "" && action != "print" && r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + actor := manualActor(w) + switch action { + case "", "print": + d := base("Поручение " + o.Number()) + d.O, d.Flash = o, r.URL.Query().Get("flash") + d.Mode = "card" + if action == "print" { + d.Mode = "print" + } + if o.Status == "draft" { + d.Handbook = manualHandbook() + d.Problems = manualDataProblems(o) + d.Blockers = manualSendBlockers(rc, o) + d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash() + } + d.Progress = manualProgressFor(r.Context(), svc, o) + d.Auto = rc.Snapshot().M2MAuto.Enabled + render(w, tpl, d) + case "checks": + _ = r.ParseForm() + err := globalManual.Update(id, func(o *ManualOrder) error { + if o.Status != "draft" { + return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status)) + } + if o.Checks == nil { + o.Checks = map[string]ManualMark{} + } + for _, c := range manualPreChecks { + manualMark(o.Checks, c.Key, r.FormValue("check_"+c.Key) != "", actor) + } + switch m := r.FormValue("quik_mode"); m { + case "auto", "manual", "none": + o.QuikMode = m + } + o.QuikInstr = strings.TrimSpace(r.FormValue("quik_instr")) + return nil + }) + msg := "" + if err != nil { + msg = err.Error() + } + back(w, r, card, msg, "#checks") + case "send": + var blockers []string + err := globalManual.Update(id, func(o *ManualOrder) error { + if blockers = manualSendBlockers(rc, o); len(blockers) > 0 { + return fmt.Errorf("отправить нельзя: %s", strings.Join(blockers, "; ")) + } + o.Status, o.SendErr = "sending", "" + return nil + }) + if err != nil { + back(w, r, card, err.Error(), "") + return + } + o, _ = globalManual.Get(id) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + guid, dealID, serr := manualSend(ctx, rc, svc, o) + cancel() + msg := "" + _ = globalManual.Update(id, func(o *ManualOrder) error { + if serr != nil { + o.Status, o.SendErr = "draft", serr.Error() + msg = "Поручение не отправлено: " + serr.Error() + return nil + } + o.Status, o.GUID, o.DealID, o.SentAt, o.SentBy = "sent", guid, dealID, time.Now(), actor + msg = "Поручение отправлено в НРД. Ход исполнения — ниже на этой странице." + return nil + }) + log.Printf("lk-gateway: ручной ввод %s (%s): отправка — %s", o.Number(), o.FullName(), msg) + back(w, r, card, msg, "") + case "decline": + reason := strings.TrimSpace(r.FormValue("reason")) + err := globalManual.Update(id, func(o *ManualOrder) error { + if o.Status != "draft" { + return fmt.Errorf("поручение уже %s", manualStatusRu(o.Status)) + } + if reason == "" { + return fmt.Errorf("укажите причину отказа") + } + o.Status, o.DeclineReason, o.DeclinedBy, o.DeclinedAt = "declined", reason, actor, time.Now() + return nil + }) + msg := "Поручение отклонено депозитарием" + if err != nil { + msg = err.Error() + } + back(w, r, card, msg, "") + case "post": + _ = r.ParseForm() + err := globalManual.Update(id, func(o *ManualOrder) error { + if o.Post == nil { + o.Post = map[string]ManualMark{} + } + for _, c := range manualPostChecks { + if _, shown := r.Form["shown_"+c.Key]; shown { + manualMark(o.Post, c.Key, r.FormValue("post_"+c.Key) != "", actor) + } + } + return nil + }) + msg := "" + if err != nil { + msg = err.Error() + } + back(w, r, card, msg, "#post") + default: + http.NotFound(w, r) + } + }) +} + +// manualMark — поставить или снять отметку (время и автор — при постановке). +func manualMark(m map[string]ManualMark, key string, on bool, actor string) { + cur := m[key] + switch { + case on && !cur.Done: + m[key] = ManualMark{Done: true, By: actor, At: time.Now()} + case !on && cur.Done: + delete(m, key) + } +} + +// manualFromForm — данные поручения из формы. +func manualFromForm(r *http.Request) *ManualOrder { + f := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } + o := &ManualOrder{ + PaperNo: f("paper_no"), PaperDate: f("paper_date"), ReceivedVia: f("received_via"), + LastName: f("last_name"), FirstName: f("first_name"), MiddleName: f("middle_name"), BirthDate: f("birth_date"), + DocType: f("doc_type"), DocSeries: strings.ReplaceAll(f("doc_series"), " ", ""), DocNumber: strings.ReplaceAll(f("doc_number"), " ", ""), + DocIssueDate: f("doc_issue_date"), DocIssuer: f("doc_issuer"), + Contract: f("contract"), Account: strings.ToUpper(f("account")), QuikCode: f("quik_code"), Phone: f("phone"), + RecvINN: f("recv_inn"), RecvName: f("recv_name"), RecvAccount: f("recv_account"), SettlePlace: f("settle_place"), + CostInfo: r.FormValue("cost_info") != "", Comment: f("comment"), + } + if o.SettlePlace != "spb" { + o.SettlePlace = "nrd" + } + isins, codes, names, qtys := r.Form["sec_isin"], r.Form["sec_code"], r.Form["sec_name"], r.Form["sec_qty"] + at := func(a []string, i int) string { + if i < len(a) { + return strings.TrimSpace(a[i]) + } + return "" + } + for i := range isins { + s := ManualSec{ISIN: strings.ToUpper(at(isins, i)), Code: at(codes, i), Name: at(names, i), Qty: at(qtys, i)} + if s.ISIN == "" && s.Qty == "" && s.Code == "" { + continue + } + o.Securities = append(o.Securities, s) + } + if o.RecvINN != "" { + o.RecvCode = handbookCodeByINN(o.RecvINN) + if o.RecvName == "" { + for _, p := range manualHandbook() { + if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) { + o.RecvName = p.Name + break + } + } + } + } + return o +} + +// manualCopyData — переносит данные формы в сохранённое поручение (служебные поля не трогает). +func manualCopyData(dst, src *ManualOrder) { + keep := *dst + *dst = *src + dst.ID, dst.CreatedAt, dst.CreatedBy, dst.UpdatedAt = keep.ID, keep.CreatedAt, keep.CreatedBy, keep.UpdatedAt + dst.Depo, dst.Checks, dst.QuikMode, dst.QuikInstr = keep.Depo, keep.Checks, keep.QuikMode, keep.QuikInstr + dst.Status, dst.GUID, dst.DealID, dst.SentAt, dst.SentBy, dst.SendErr = keep.Status, keep.GUID, keep.DealID, keep.SentAt, keep.SentBy, keep.SendErr + dst.DeclineReason, dst.DeclinedBy, dst.DeclinedAt, dst.Post = keep.DeclineReason, keep.DeclinedBy, keep.DeclinedAt, keep.Post +} + +// manualDoLookup — заполнить из DEPO пустые ФИО, дату рождения, счёт и наименования бумаг. +func manualDoLookup(rc *RuntimeConfig, id int) string { + o, ok := globalManual.Get(id) + if !ok { + return "поручение не найдено" + } + if strings.TrimSpace(o.DocNumber) == "" { + return "Для поиска в DEPO укажите серию и номер документа" + } + c := manualDepoLookup(rc, o.DocSeries, o.DocNumber) + if !c.Found { + return "Поиск в DEPO: " + c.Err + } + var isins []string + for _, s := range o.Securities { + if s.Name == "" && s.ISIN != "" { + isins = append(isins, s.ISIN) + } + } + names := manualDepoSecNames(rc, isins) + var filled []string + _ = globalManual.Update(id, func(o *ManualOrder) error { + old := o.dataHash() + if o.LastName == "" && o.FirstName == "" { + if p := strings.Fields(c.Name); len(p) >= 2 { + o.LastName, o.FirstName = p[0], p[1] + if len(p) > 2 { + o.MiddleName = strings.Join(p[2:], " ") + } + filled = append(filled, "ФИО") + } + } + if o.BirthDate == "" && c.Birth != "" { + o.BirthDate = c.Birth + filled = append(filled, "дата рождения") + } + if o.Account == "" && c.Account != "" { + o.Account = c.Account + filled = append(filled, "счёт "+c.Account) + } + for i := range o.Securities { + if n := names[o.Securities[i].ISIN]; n != "" && o.Securities[i].Name == "" { + o.Securities[i].Name = n + filled = append(filled, "наименование "+o.Securities[i].ISIN) + } + } + if o.dataHash() != old { + o.Depo, o.Checks = nil, nil + } + return nil + }) + msg := "Клиент в DEPO: " + c.Name + if c.Account == "" { + msg += " (открытого торгового счёта нет)" + } + if len(filled) > 0 { + msg += ". Заполнено: " + strings.Join(filled, ", ") + " — проверьте с поручением клиента" + } else { + msg += ". Пустых полей для заполнения нет" + } + return msg +} + +// manualDoDepo — сверка с DEPO. +func manualDoDepo(rc *RuntimeConfig, id int, actor string) string { + o, ok := globalManual.Get(id) + if !ok { + return "поручение не найдено" + } + if p := manualDataProblems(o); len(p) > 0 { + return "Сверка не выполнена — сначала заполните реквизиты: " + strings.Join(p, "; ") + } + chk := manualDepoRun(rc, o) + chk.By = actor + _ = globalManual.Update(id, func(o *ManualOrder) error { + o.Depo = chk + return nil + }) + if !chk.Ran { + return "Сверка с DEPO не выполнена: " + chk.Err + } + return "Сверка с DEPO: " + chk.Summary +} diff --git a/internal/lkgateway/manual_test.go b/internal/lkgateway/manual_test.go new file mode 100644 index 0000000..db6b543 --- /dev/null +++ b/internal/lkgateway/manual_test.go @@ -0,0 +1,144 @@ +package lkgateway + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func manualTestOrder() *ManualOrder { + return &ManualOrder{ + Status: "draft", PaperNo: "15", PaperDate: "2026-09-17", ReceivedVia: "лично в офисе", + LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович", BirthDate: "1980-01-02", + DocType: "21", DocSeries: "0000", DocNumber: "000000", Account: "СЧЁТ-1", + RecvINN: "7702165310", RecvName: "Депозитарий", SettlePlace: "nrd", + Securities: []ManualSec{{ISIN: "RU0009029540", Qty: "10"}}, + } +} + +func TestManualSendBlockers(t *testing.T) { + rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json") + if err != nil { + t.Fatal(err) + } + o := manualTestOrder() + b := manualSendBlockers(rc, o) + if len(b) != len(manualPreChecks)+1 { // все отметки + способ блокировки в QUIK + t.Fatalf("без отметок: %v", b) + } + o.Checks = map[string]ManualMark{} + for _, c := range manualPreChecks { + o.Checks[c.Key] = ManualMark{Done: true} + } + o.QuikMode = "manual" + if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "номер поручения") { + t.Fatalf("ручная блокировка без номера: %v", b) + } + o.QuikInstr = "123" + if b = manualSendBlockers(rc, o); len(b) != 0 { + t.Fatalf("всё заполнено: %v", b) + } + o.QuikMode = "auto" // цикл QUIK не включён + if b = manualSendBlockers(rc, o); len(b) != 1 { + t.Fatalf("автоблокировка недоступна: %v", b) + } + o.QuikMode = "none" + o.Securities = append(o.Securities, ManualSec{ISIN: "US0378331005", Qty: "0"}) + if b = manualSendBlockers(rc, o); len(b) != 2 { + t.Fatalf("иностранная бумага и нулевое количество: %v", b) + } +} + +func TestManualQtyAndClaim(t *testing.T) { + if q, ok := manualQty("1,5"); !ok || q.Fractional != "1.5" { + t.Fatalf("дробное: %+v", q) + } + if q, ok := manualQty("7"); !ok || q.Whole != 7 { + t.Fatalf("целое: %+v", q) + } + if _, ok := manualQty("-1"); ok { + t.Fatal("отрицательное") + } + rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") + c, err := manualClaim(rc, manualTestOrder()) + if err != nil || c.ClientAccount != "СЧЁТ-1" || c.Investor.BirthDate != "1980-01-02" || c.Securities[0].SecurityCode != "RU0009029540" || c.CostInfo.No == nil { + t.Fatalf("заявка: %+v %v", c, err) + } +} + +// Страницы раздела: список, форма, карточка черновика, отметки, отказ, печать. +func TestManualPages(t *testing.T) { + saved := globalManual + globalManual = loadManualStore(t.TempDir() + "/manual.json") + defer func() { globalManual = saved }() + rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + registerManual(mux, rc, nil) + do := func(method, path string, form url.Values) *httptest.ResponseRecorder { + var req *http.Request + if form != nil { + req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req = httptest.NewRequest(method, path, nil) + } + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec + } + if rec := do("GET", "/admin/help/manual", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Порядок работы") { + t.Fatalf("инструкция: %d", rec.Code) + } + if rec := do("GET", "/admin/manual/new", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Новое поручение") { + t.Fatalf("форма: %d %s", rec.Code, rec.Body.String()) + } + form := url.Values{"paper_no": {"15"}, "paper_date": {"2026-09-17"}, "last_name": {"Иванов"}, "first_name": {"Иван"}, + "birth_date": {"1980-01-02"}, "doc_type": {"21"}, "doc_series": {"00 00"}, "doc_number": {"000000"}, "account": {"счёт-1"}, + "recv_inn": {"7702165310"}, "sec_isin": {"ru0009029540", ""}, "sec_qty": {"10", ""}, "sec_code": {"", ""}, "sec_name": {"", ""}, "do": {"save"}} + rec := do("POST", "/admin/manual/save", form) + if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/admin/manual/1") { + t.Fatalf("сохранение: %d %s", rec.Code, rec.Header().Get("Location")) + } + o, _ := globalManual.Get(1) + if o.DocSeries != "0000" || o.Account != "СЧЁТ-1" || len(o.Securities) != 1 || o.Securities[0].ISIN != "RU0009029540" { + t.Fatalf("данные: %+v", o) + } + if rec = do("GET", "/admin/manual/1", nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Отправить пока нельзя") { + t.Fatalf("карточка: %d %s", rec.Code, rec.Body.String()) + } + chk := url.Values{"quik_mode": {"none"}} + for _, c := range manualPreChecks { + chk.Set("check_"+c.Key, "1") + } + do("POST", "/admin/manual/1/checks", chk) + o, _ = globalManual.Get(1) + if len(manualSendBlockers(rc, o)) != 0 || o.Checks["paper"].At.IsZero() { + t.Fatalf("отметки: %+v %v", o.Checks, manualSendBlockers(rc, o)) + } + if rec = do("GET", "/admin/manual/1", nil); !strings.Contains(rec.Body.String(), "Все проверки и отметки пройдены") { + t.Fatalf("готово к отправке: %s", rec.Body.String()) + } + // Изменение данных сбрасывает отметки. + form.Set("id", "1") + form.Set("sec_qty", "11") + do("POST", "/admin/manual/save", form) + if o, _ = globalManual.Get(1); len(o.Checks) != 0 { + t.Fatalf("отметки должны сброситься: %+v", o.Checks) + } + do("POST", "/admin/manual/1/decline", url.Values{"reason": {"нет подписи клиента"}}) + o, _ = globalManual.Get(1) + if o.Status != "declined" || o.DeclinedAt.Before(time.Now().Add(-time.Minute)) { + t.Fatalf("отказ: %+v", o) + } + for _, p := range []string{"/admin/manual", "/admin/manual/1", "/admin/manual/1/print"} { + if rec = do("GET", p, nil); rec.Code != 200 || !strings.Contains(rec.Body.String(), "нет подписи клиента") && p != "/admin/manual" { + t.Fatalf("%s: %d %s", p, rec.Code, rec.Body.String()) + } + } +} diff --git a/internal/lkgateway/news.go b/internal/lkgateway/news.go index 9a31c40..187236a 100644 --- a/internal/lkgateway/news.go +++ b/internal/lkgateway/news.go @@ -402,10 +402,35 @@ func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary)) } +// devNewsIDs — стартовые новости для разработчиков (TEST3, робот НРД): у клиента не показываем. +var devNewsIDs = map[string]bool{ + "test3-maintenance-2026-05": true, + "robot-autotest-2026-05-12": true, +} + +// visibleNews — новости, которые видны на этой установке: вне режима разработки +// без служебных новостей про тестирование. +func visibleNews(rc *RuntimeConfig, items []NewsItem) []NewsItem { + if devMode(rc) { + return items + } + out := make([]NewsItem, 0, len(items)) + for _, it := range items { + if !devNewsIDs[it.ID] { + out = append(out, it) + } + } + return out +} + // SeedDefaultNews добавляет в ленту известные на момент запуска события // (окно техработ TEST3 в мае 2026 и появление робота-автотестирования). // Вызывается из server.go при старте — дедуп по ID гарантирован AddNews. +// Только в режиме разработки: клиентам эти новости не нужны. func SeedDefaultNews(rc *RuntimeConfig) { + if !devMode(rc) { + return + } defaults := []NewsItem{ { ID: "test3-maintenance-2026-05", diff --git a/internal/lkgateway/quikdb_handlers.go b/internal/lkgateway/quikdb_handlers.go index b90921e..303fd5f 100644 --- a/internal/lkgateway/quikdb_handlers.go +++ b/internal/lkgateway/quikdb_handlers.go @@ -112,7 +112,9 @@ func (h *setupHandlers) saveQuikCycle(w http.ResponseWriter, r *http.Request) { return } mode := "записи в МНП ВЫКЛЮЧЕНЫ" - if s.Emulate { + if s.Emulate && !devMode(h.rc) { + mode = "цикл остановлен — записи в МНП не выполняются" + } else if s.Emulate { mode = fmt.Sprintf("ИМИТАЦИЯ (в МНП ничего не пишется): исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal)) } else if s.WritesEnabled { mode = fmt.Sprintf("записи включены: исходящие %s, входящие %s, поручения из терминала %s", onOff(s.AutoOut), onOff(s.AutoIn), onOff(s.PollTerminal)) @@ -176,7 +178,7 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) { } now := time.Now() var b strings.Builder - fmt.Fprintf(&b, "=== Тест подключения к БД МНП QUIK ===\n") + 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)) diff --git a/internal/lkgateway/quikhooks.go b/internal/lkgateway/quikhooks.go index 3760eac..ca9fdab 100644 --- a/internal/lkgateway/quikhooks.go +++ b/internal/lkgateway/quikhooks.go @@ -45,7 +45,7 @@ func quikSecISIN(s m2m.RequestSecurity) string { // ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { q := rc.Snapshot().Quik - if !quikActive(q) || !q.AutoOut || req == nil { + if !quikActive(q) || !q.AutoOut || req == nil || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником return nil } guid := string(req.Header.GUID) diff --git a/internal/lkgateway/quikm2m.go b/internal/lkgateway/quikm2m.go index 733d0b8..3adfd85 100644 --- a/internal/lkgateway/quikm2m.go +++ b/internal/lkgateway/quikm2m.go @@ -45,8 +45,8 @@ const ( // errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока // администратор не завершил настройку в QAdmin (классы/типы) и не выделен -// безопасный контур/клиент для теста. Снимается через quikWritesAllowed. -var errQuikWritesDisabled = errors.New("quik: пишущие операции M2M отключены (нужны: включение типов 100/101 в QAdmin, классы INSTROUTM2M/INSTRINM2M, тестовый клиент)") +// явное разрешение записи. Снимается через quikWritesAllowed. +var errQuikWritesDisabled = errors.New("quik: пишущие операции M2M отключены (нужны: включение типов 100/101 в QAdmin, классы INSTROUTM2M/INSTRINM2M, разрешение записи в МНП в настройках bj)") // quikWritesAllowed — глобальный предохранитель пишущих вызовов к боевой БД QUIK. // По умолчанию false. Включается осознанно (флаг/настройка), когда контур готов. diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 242f023..2e84dab 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -656,14 +656,20 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness { func posMsg(dsn string) string { if dsn == "" { - return "DSN не задан — система в режиме in-memory (M2-демо)" + if devMode(globalRC) { + return "DSN не задан — система в режиме in-memory (M2-демо)" + } + return "База данных не настроена — данные не сохраняются между перезапусками" } return "DSN настроен: " + maskDSN(dsn) } func cryptoMsg(c CryptoSettings) string { if c.Provider == "" || c.Provider == "stub" { - return "Криптография не настроена (provider=stub) — реальная подпись недоступна." + if devMode(globalRC) { + return "Криптография не настроена (provider=stub) — реальная подпись недоступна." + } + return "Средство подписи (СКЗИ) не настроено — подпись и обмен с НРД недоступны." } if c.ModulePath == "" { return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан." @@ -673,7 +679,10 @@ func cryptoMsg(c CryptoSettings) string { func nsdMsg(n NSDSettings) string { if n.IGWBaseURL == "" { - return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)" + if devMode(globalRC) { + return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)" + } + return "ИШ НРД не настроен — обмен с НРД не выполняется" } if n.Profile == "" { return "URL ИШ задан, но профиль не выбран" @@ -683,7 +692,10 @@ func nsdMsg(n NSDSettings) string { func lkMsg(l LKSettings) string { if l.CallbackURL == "" { - return "Callback URL не настроен — используется встроенный lk-emulator" + if devMode(globalRC) { + return "Callback URL не настроен — используется встроенный lk-emulator" + } + return "Callback URL не настроен — статусы в личный кабинет не отправляются" } return "Callback URL: " + l.CallbackURL } diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 7ed01bf..88af97b 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -145,7 +145,10 @@ func NewServer(cfg ServerConfig) (*Server, error) { // CheckOptions берётся из runtime-конфига при каждом запросе на дашборд. checkOpts := func() CheckOptions { s := rc.Snapshot() - profile := "demo (mock NSD)" + profile := "не настроен" + if devMode(rc) { + profile = "demo (mock NSD)" + } if s.NSD.Profile != "" { profile = s.NSD.Profile } @@ -190,6 +193,7 @@ func NewServer(cfg ServerConfig) (*Server, error) { } globalOutgoingDeps = &outDeps // автоматический режим M2M registerOutgoing(mux, adminTpl, outDeps) + registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям registerJournal(mux, adminTpl, svc, incomingStore) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerHealth(mux) diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go index ddc94a8..2c5f4ae 100644 --- a/internal/lkgateway/service.go +++ b/internal/lkgateway/service.go @@ -646,7 +646,7 @@ func callbackForDeal(d *m2mcore.Deal) StatusCallback { for _, sec := range d.Decision.Data.Securities { if sec.TransferDecision.Rejection != nil && len(sec.TransferDecision.Rejection.Codes) > 0 { cb.ReasonCode = sec.TransferDecision.Rejection.Codes[0] - cb.ReasonText = "Отказ принимающей стороны (mock)" + cb.ReasonText = "Отказ принимающей стороны" break } } diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 50ea395..fde2e53 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -138,6 +138,7 @@ func (h *setupHandlers) renderNews(w http.ResponseWriter, r *http.Request) { return } s := h.rc.Snapshot() + s.News.Items = visibleNews(h.rc, s.News.Items) data := struct { page Settings Settings @@ -271,7 +272,7 @@ func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) { payload := []byte("Bridge-and-Join-s sign smoke test") sig, err := cli.SignXMLDSig(r.Context(), payload, "", s.Crypto.Profile) if err != nil { - setupFlash(w, r, "Тестовая подпись не удалась: "+err.Error()) + setupFlash(w, r, "Пробная подпись не удалась: "+err.Error()) return } outPath := "/var/lib/bj/.bj/test-sign.p7s" @@ -282,7 +283,7 @@ func (h *setupHandlers) testSignCrypto(w http.ResponseWriter, r *http.Request) { if len(b64Preview) > 80 { b64Preview = b64Preview[:80] + "..." } - setupFlash(w, r, fmt.Sprintf("Тестовая подпись OK: CMS detached, %d байт. Сохранена в %s. Префикс base64: %s", + setupFlash(w, r, fmt.Sprintf("Пробная подпись OK: CMS detached, %d байт. Сохранена в %s. Префикс base64: %s", len(sig), outPath, b64Preview)) } @@ -388,7 +389,7 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) { // Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше // просто ошибка — это нормально, важно показать что DSN сохранён). if err := tryPingPostgres(dsn); err != nil { - setupFlash(w, r, "postgres: тест соединения упал: "+err.Error()) + setupFlash(w, r, "postgres: проверка соединения не прошла: "+err.Error()) return } } @@ -758,7 +759,11 @@ func (h *setupHandlers) lkPull(w http.ResponseWriter, r *http.Request) { 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 devMode(h.rc) { + msg = fmt.Sprintf("Забор заявок из ЛК (имитация НРД — %s): получено %d, прошли сверку и переданы имитатору %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected) + } else { + msg = fmt.Sprintf("Забор заявок из ЛК (без отправки в НРД — %s): получено %d, прошли сверку %d, отклонены сверкой %d", why, res.Fetched, res.Sent, res.Rejected) + } } if len(res.Errors) > 0 { msg += " · ошибки: " + strings.Join(res.Errors, "; ") @@ -800,17 +805,17 @@ func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) { return } a := h.rc.Snapshot().Alerts - err := sendSMTP(a.SMTP, "[bj] Проверка уведомлений", "Это тестовое письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.") + 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()) + setupFlash(w, r, "Проверка SMTP: "+err.Error()) return } - a.SMTP.LastMsg = "тестовое письмо отправлено" + a.SMTP.LastMsg = "проверочное письмо отправлено" _ = h.rc.UpdateAlerts(a) - setupFlash(w, r, "Тест SMTP: письмо отправлено на "+a.SMTP.To) + setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+a.SMTP.To) } // saveAlertsZabbix сохраняет настройки Zabbix-хука. @@ -848,12 +853,12 @@ func (h *setupHandlers) testAlertsZabbix(w http.ResponseWriter, r *http.Request) if err != nil { a.Zabbix.LastMsg = "ошибка: " + err.Error() _ = h.rc.UpdateAlerts(a) - setupFlash(w, r, "Тест Zabbix: "+err.Error()) + setupFlash(w, r, "Проверка Zabbix: "+err.Error()) return } a.Zabbix.LastMsg = "принято: " + info _ = h.rc.UpdateAlerts(a) - setupFlash(w, r, "Тест Zabbix: сервер принял значение ("+info+")") + setupFlash(w, r, "Проверка Zabbix: сервер принял значение ("+info+")") } // saveIncomingNotify сохраняет настройки уведомлений депозитарию о входящих. @@ -894,26 +899,26 @@ func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Reques } cfg := h.rc.Snapshot() if strings.TrimSpace(cfg.Incoming.Recipients("incoming")) == "" { - setupFlash(w, r, "Тест: сначала укажите почту депозитария") + setupFlash(w, r, "Проверка: сначала укажите почту депозитария") return } smtp := cfg.Alerts.SMTP if !smtp.Enabled || strings.TrimSpace(smtp.Host) == "" { - setupFlash(w, r, "Тест: сначала настройте и включите SMTP (раздел «Почта»)") + setupFlash(w, r, "Проверка: сначала настройте и включите SMTP (раздел «Почта»)") return } sample := &IncomingTransfer{ GUID: "00000000-0000-0000-0000-000000000000", - SenderCode: "MC0012500000", SenderINN: "7722061076", + SenderCode: "MC0000000000", SenderINN: "0000000000", // обезличенный образец 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()) + if err := sendSMTP(smtp, "[ПРОВЕРКА] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil { + setupFlash(w, r, "Проверка уведомления депозитарию: "+err.Error()) return } - setupFlash(w, r, "Тест: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("incoming")) + setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("incoming")) } // lkPostJSON — POST JSON к API ЛК с Basic; возвращает (код, тело). diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index 967366f..d2c5c08 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -58,7 +58,7 @@ {{end}} -{{with .Claim.TestResult}} +{{if .DevMode}}{{with .Claim.TestResult}}

{{if eq .Status "ok"}}✓ Тест пройден @@ -73,7 +73,7 @@ {{if .Note}}Вывод{{.Note}}{{end}}

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

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

@@ -235,7 +235,7 @@ {{if .Claim.RequestRawXML}}

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

-

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

+

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

⤓ Скачать XML запроса
{{.Claim.RequestRawXML}}
diff --git a/internal/lkgateway/web/templates/admin_fancy_queue.html b/internal/lkgateway/web/templates/admin_fancy_queue.html index 481d489..9dd3107 100644 --- a/internal/lkgateway/web/templates/admin_fancy_queue.html +++ b/internal/lkgateway/web/templates/admin_fancy_queue.html @@ -68,7 +68,7 @@
{{else if not .Error}} -

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

+

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

{{end}}
diff --git a/internal/lkgateway/web/templates/admin_handbook.html b/internal/lkgateway/web/templates/admin_handbook.html index e3b7021..07341ee 100644 --- a/internal/lkgateway/web/templates/admin_handbook.html +++ b/internal/lkgateway/web/templates/admin_handbook.html @@ -7,7 +7,7 @@ {{if not .Participants}}
Справочник ещё не загружен. -

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

+

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

{{end}} diff --git a/internal/lkgateway/web/templates/admin_help.html b/internal/lkgateway/web/templates/admin_help.html index bc31f48..c7ba487 100644 --- a/internal/lkgateway/web/templates/admin_help.html +++ b/internal/lkgateway/web/templates/admin_help.html @@ -20,13 +20,19 @@

Криптография (Валидата) →

-

Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.

+

Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, проверка подписи и квитанций НРД.

Внешние системы →

-

ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.

+

ИШ НРД ({{if .DevMode}}профили GUEST/TEST3/PROD{{else}}тестовый и боевой контуры{{end}}), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.

+
+
+ +
+

Ручной ввод M2M →

+

Исходящий перевод по бумажному поручению клиента: ввод, сверка с учётной и торговой системами, обязательные отметки, отправка, поручение 16, документы НРД.

{{if .DevMode}} @@ -38,7 +44,7 @@

Архитектура обмена с НРД →

-

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

+

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

diff --git a/internal/lkgateway/web/templates/admin_help_architecture.html b/internal/lkgateway/web/templates/admin_help_architecture.html index 391e57d..7905094 100644 --- a/internal/lkgateway/web/templates/admin_help_architecture.html +++ b/internal/lkgateway/web/templates/admin_help_architecture.html @@ -21,7 +21,7 @@ │ │ • стейт-машина │ │ Делает САМ: │ │ │ │ • PostgreSQL │ │ • подпись │ │ │ │ • админка :8080 │ │ • упаковку ЭДО │ │ -│ │ • lk-emulator │ │ • проверку │ │ +│ │ {{if .DevMode}}• lk-emulator {{else}}• коннектор ЛК {{end}}│ │ • проверку │ │ │ │ │ │ подписей НРД │ │ │ └──────────────────┘ │ • БД PostgreSQL │ │ │ │ (history) │ │ @@ -46,9 +46,9 @@ │ /onyxpr/WslService │ │ │ │ ↓ внутрь НРД │ - │ • робот-автотест │ +{{if .DevMode}} │ • робот-автотест │ │ MC0012500000 │ - │ • реальные депозитарии │ +{{end}} │ • реальные депозитарии │ │ • биржевые системы │ └────────────────────────────┘ @@ -64,7 +64,7 @@ наша Astra Linux SE 1.7 / Linux АПК «Валидата Клиент L» (PKCS#11) - Стейт-машина, журнал в БД, веб-админка, lk-emulator + Стейт-машина, журнал в БД, веб-админка{{if .DevMode}}, lk-emulator{{end}} ИШ (igate) @@ -80,13 +80,13 @@ — Web-сервис НРД — принимает пакеты от ИШ всех клиентов - + {{if .DevMode}} Робот-автотест НРД — — Контрагент-эмулятор внутри НРД. Адресуется кодом MC0012500000 в TEST3 - + {{end}} @@ -109,6 +109,7 @@

Q: Какой сертификат нужен?

Только от УЦ Московской Биржи (ca.moex.com). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).

+ {{if .DevMode}}

Q: Что делать, чтобы протестировать на роботе НРД на TEST3?

  1. Получить сертификат УЦ МБ для нашей организации.
  2. @@ -121,8 +122,18 @@

    Q: Сколько времени нужно от старта подключения до прогона на TEST3?

    Оптимистично — 1-2 недели (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — 3-4 недели. На нашей стороне всё уже готово; задержка только во внешних шагах.

    + {{else}} +

    Q: Что нужно, чтобы начать обмен с НРД?

    +
      +
    1. Получить сертификат УЦ МБ для организации.
    2. +
    3. Оформить в НРД подключение к ЭДО и сервису MOEX МОСТ (M2M), получить доступ к нужному контуру.
    4. +
    5. Поднять Astra Linux ВМ, поставить ИШ + Валидату, импортировать сертификат.
    6. +
    7. В Настройке → «НРД» указать профиль, URL ИШ и ключевой контейнер, затем проверить подключения на странице «Статус».
    8. +
    + {{end}} +{{if .DevMode}}

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

      @@ -134,3 +145,4 @@
    {{end}} +{{end}} diff --git a/internal/lkgateway/web/templates/admin_help_fancy_events.html b/internal/lkgateway/web/templates/admin_help_fancy_events.html index 23cd703..7b6c34f 100644 --- a/internal/lkgateway/web/templates/admin_help_fancy_events.html +++ b/internal/lkgateway/web/templates/admin_help_fancy_events.html @@ -18,7 +18,7 @@ Действия джобы по событиям Справочник участников Кто запускает обработку - Выгрузка истории тестов + {{if .DevMode}}Выгрузка истории тестов{{end}}
    Практика
    Примеры SQL Открытые вопросы @@ -184,6 +184,7 @@ ORDER BY e.ID;

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

    + {{if .DevMode}}{{/* отчёт о выгрузке истории тестирования — только в режиме разработки */}}

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

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

      @@ -192,14 +193,15 @@ ORDER BY e.ID;
    • После обработки событий статусы в 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 без действий.
    + {{end}}

    Примеры SQL

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

    BEGIN TRAN;
     INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC)
    -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Погонщикова Виктория Олеговна', '4500 123456');
    +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'IN', 'MC0034900000', N'Иванов Иван Иванович', '0000 000000');
     INSERT INTO dbo.FTV_M2M_LINES (GUID, REFERENCE, ISIN, SEC_CODE, SHARE, AMOUNT, PLACE_NUMBER, DEPO_NUMBER)
    -VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00', N'ВТНКЦ00000816');
    +VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0413600000F00', N'ВТНКЦ00000001');
     INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE)
     VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_RECEIVED', '2026-09-17T09:21:26.000');   -- время получения M2MTR в bj
     COMMIT;
    diff --git a/internal/lkgateway/web/templates/admin_help_lk.html b/internal/lkgateway/web/templates/admin_help_lk.html
    index 5fe0ca1..3dd2d8d 100644
    --- a/internal/lkgateway/web/templates/admin_help_lk.html
    +++ b/internal/lkgateway/web/templates/admin_help_lk.html
    @@ -12,7 +12,7 @@
       
    curl -u "lk-user:lk-password" \
       -H "Content-Type: application/json" \
       http://10.10.10.22:8080/api/v1/back_office/claims/
    -

    На M2 (дев-стенд) аутентификация отключена; включится на M3 — параллельно с подключением реального ЛК.

    + {{if .DevMode}}

    На M2 (дев-стенд) аутентификация отключена; включится на M3 — параллельно с подключением реального ЛК.

    {{end}}
    @@ -75,6 +75,7 @@ }
    +{{if .DevMode}}

    7. Эмулятор ЛК для тестов

    На дев-стенде доступен lk-emulator на порту 8083 — имитация ЛК. Он сам регистрирует свой URL в bj-server как callback-приёмник.

    @@ -84,10 +85,11 @@
  3. При запуске реального ЛК эмулятор остаётся как QA-инструмент
  4. +{{end}}
    -

    8. Подписание заявления

    +

    {{if .DevMode}}8{{else}}7{{end}}. Подписание заявления

    ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле signed_document (base64). Мы проверяем подпись через crypto-service — см. инструкцию по криптографии.

    -

    На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.

    + {{if .DevMode}}

    На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.

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

    ← Справка К разделу

    +

    Ручной ввод M2M: инструкция

    +

    Раздел «Ручной ввод M2M» нужен, когда клиент подаёт поручение на перевод ценных бумаг на бумаге (в офисе, через представителя, почтой). Сотрудник депозитария вводит поручение в систему так, как клиент заполнил бы заявку в личном кабинете. После отправки перевод идёт тем же путём, что и заявка из личного кабинета, с той же автоматизацией.

    +

    Раздел работает только для исходящих переводов, когда бумаги списываются у нас. Входящие переводы приходят от контрагентов и обрабатываются в разделе «Журнал → Входящие».

    + +

    Кто работает в разделе

    +
      +
    • Просматривать поручения могут все пользователи с правом просмотра.
    • +
    • Вводить, сверять, отмечать и отправлять поручения может пользователь с правом «Операции».
    • +
    • Каждая отметка сохраняет, кто и когда её поставил. Эти данные попадают в лист сверки для печати.
    • +
    + +

    Порядок работы

    +
      +
    1. Примите поручение. Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.
    2. +
    3. Создайте поручение в системе: «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).
    4. +
    5. Найдите клиента в учётной системе кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.
    6. +
    7. Выполните сверку с DEPO кнопкой «Сохранить и сверить с DEPO». Система проверяет: +
        +
      • клиента по документу, ФИО и дате рождения;
      • +
      • что счёт принадлежит клиенту, открыт и не является ИИС;
      • +
      • что выпуск российский, а бумага есть в справочнике и принята на обслуживание;
      • +
      • что остаток на торговом разделе достаточен и бумаги не заблокированы;
      • +
      • место хранения в НРД;
      • +
      • ограничения для квалифицированных инвесторов (как предупреждение).
      • +
      + Это те же проверки, что и для заявок из личного кабинета. Если сверка не пройдена, отправить поручение нельзя.
    8. +
    9. Проставьте обязательные отметки. Каждая отметка подтверждает, что вы лично проверили пункт в учётной системе депозитария. Выберите, как заблокированы бумаги в торговой системе.
    10. +
    11. Отправьте поручение кнопкой «Отправить в НРД». Кнопка доступна, только когда заполнены все реквизиты, сверка пройдена и все отметки проставлены. Над кнопкой показано, чего не хватает.
    12. +
    13. Следите за ходом исполнения в карточке поручения (подробнее — ниже).
    14. +
    15. Закройте поручение отметками после ответа: проведено ли списание в торговой и учётной системах, уведомлён ли клиент, есть ли отметка на бумажном поручении.
    16. +
    17. Распечатайте лист сверки («Лист сверки для печати») и приложите его к бумажному поручению.
    18. +
    + +

    Поля поручения

    +
    + + + + + + + + + + + +
    Номер и дата порученияРеквизиты бумажного поручения клиента (входящий номер депозитария).
    Как полученоСпособ получения: лично, через представителя, почтой, курьером, через систему электронного документооборота.
    Вид документа, серия, номерДокумент, удостоверяющий личность, по справочнику НРД. Для паспорта РФ (код 21) серия — 4 цифры, номер — 6 цифр. Принимающий депозитарий ищет клиента по этим данным, поэтому они должны точно совпадать с анкетой.
    ФИО, дата рожденияКак в документе. Дата рождения сверяется с анкетой депонента.
    Счёт депо клиентаТорговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.
    Договор, код в торговой системеДля сведения и поиска клиента в торговой системе.
    Ценные бумагиISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.
    Принимающий депозитарийВыбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.
    Счёт у принимающегоДля сведения: принимающий депозитарий сам находит клиента по документу.
    Место расчётовНРД или СПБ Банк — как в поручении клиента.
    Сведения о расходахОтметьте, если клиент просит передать сведения о расходах на приобретение бумаг.
    +

    Если после сверки или отметок изменить данные поручения, сверка и отметки сбросятся: их нужно пройти заново.

    + +

    Блокировка в торговой системе

    +
      +
    • Заблокировать автоматически при отправке. Вариант доступен, если в настройках включён автоматический цикл в модуле неторговых поручений QUIK. bj регистрирует вывод бумаг, и лимит уменьшается. По исполнению bj подписывает списание, а при отказе снимает блокировку.
    • +
    • Заблокированы вручную. Сотрудник сам регистрирует поручение на вывод в торговой системе и указывает его номер. После ответа сотрудник отмечает окончательное списание или снятие блокировки.
    • +
    • Не требуется. Выберите этот вариант, если бумаги не учитываются в торговой системе.
    • +
    + +

    Ход исполнения

    +
      +
    1. Пакет отправлен в НРД. Сервис MOEX МОСТ проверяет формат и присылает квитанцию. Если есть ошибка, поручение завершается отказом с кодом, а блокировка снимается.
    2. +
    3. Решение принимающего депозитария. Депозитарий соглашается или отказывает (код отказа виден в карточке). Пакет принимается только целиком.
    4. +
    5. Черновик поручения 16. После согласия НРД присылает черновик поручения на списание.
    6. +
    7. Поручение 16 подано. Нажмите «Подать поручение 16 по черновику». Если включён автоматический режим M2M, bj подаёт поручение сам.
    8. +
    9. Отчёт НРД об исполнении. Когда отчёт (MS010) получен, бумаги списаны. Кнопка «Документы НРД» скачивает архив с квитанциями и отчётом, в том числе в PDF.
    10. +
    +

    Сведения о переводе автоматически передаются в учётную систему депозитария, как для заявок из личного кабинета. Полная техническая информация (пакеты, ответы НРД) — в «Карточке перевода».

    + +

    Отклонение поручения

    +

    Если поручение нельзя исполнить (например, нет подписи, недостаточно бумаг или депозитарий не участвует в M2M), нажмите «Отклонить поручение…» и укажите причину. В НРД ничего не отправляется. Уведомите клиента и сделайте отметку на бумажном поручении.

    + +

    Частые вопросы

    +
      +
    • Можно ли изменить поручение после отправки? Нет. Если данные неверны, дождитесь отказа или исполнения и введите новое поручение.
    • +
    • Отправка не удалась. Причина показана в карточке, поручение остаётся черновиком. Устраните причину (например, связь с НРД или торговой системой) и отправьте снова.
    • +
    • Нет кнопки «Найти в DEPO». Учётная система не подключена к bj. Заполните поля вручную и выполните сверку в учётной системе сами: отметки подтверждают эту проверку.
    • +
    • Справочник участников пуст. Администратор может запросить справочник в «Настройка → Диагностика», а пока ИНН можно ввести вручную.
    • +
    +
    +{{end}} diff --git a/internal/lkgateway/web/templates/admin_help_quik.html b/internal/lkgateway/web/templates/admin_help_quik.html index 3a209a7..0e0781b 100644 --- a/internal/lkgateway/web/templates/admin_help_quik.html +++ b/internal/lkgateway/web/templates/admin_help_quik.html @@ -127,7 +127,7 @@

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

    1. В bj: Настройка → QUIK МНП → «Проверить подключение». В отчёте должны быть видны процедуры sp_NewInstrSec, sp_SignInstrSec, cancel_instruction и таблица M2MDepoRecipients.
    2. -
    3. Скажите нам «проверяйте». Мы на согласованном тестовом клиенте регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.
    4. +
    5. Скажите нам «проверяйте». Мы на согласованном клиенте для проверки регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.
    6. Признак готовности в самой базе МНП: у типов 100 и 101 снят признак Disabled, у классов INSTROUTM2M / INSTRINM2M есть фирма, инструменты привязаны к классам. Это мы проверяем сами.
    @@ -149,7 +149,7 @@
  5. Точные коды инструментов, если они отличаются от RUROUTM2M / RURINM2M (шаг 3).
  6. Что типы 100/101 включены и привязаны к классам (шаг 4), права техпользователю выданы (шаг 5).
  7. Время перезапуска модуля (шаг 7).
  8. -
  9. Код тестового клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.
  10. +
  11. Код клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.
  12. Документы ARQA

    diff --git a/internal/lkgateway/web/templates/admin_help_systems.html b/internal/lkgateway/web/templates/admin_help_systems.html index d7dd7c7..5bc7936 100644 --- a/internal/lkgateway/web/templates/admin_help_systems.html +++ b/internal/lkgateway/web/templates/admin_help_systems.html @@ -7,7 +7,7 @@
    -

    1. Интеграционный шлюз НРД (ИШ) и контуры тестирования

    +

    1. Интеграционный шлюз НРД (ИШ) и контуры НРД

    Основной канал отправки M2M-сообщений в НРД — Web-сервис ONYX через ИШ. ИШ сам подписывает пакеты ЭДО, поэтому в этом канале нам криптография не требуется. Дистрибутив ИШ скачивается с сайта НРД: www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4

    Адреса контуров (из DOC/Ссылки для доступа в тестовые контуры.pdf):

    @@ -45,7 +45,7 @@
  13. URL ONYX — например https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo
  14. Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)
  15. -

    Без настроенного ИШ система работает в mock-режиме: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.

    + {{if .DevMode}}

    Без настроенного ИШ система работает в mock-режиме: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.

    {{else}}

    Пока ИШ не настроен, обмен с НРД не выполняется: поручения в НРД не отправляются.

    {{end}}
    @@ -96,10 +96,10 @@
    - - + + - +
    Внешняя сторонаЧто согласовать
    НРД (Национальный расчётный депозитарий)Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД
    Команда ЛК (ESIA Finance)Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)
    НРД (Национальный расчётный депозитарий){{if .DevMode}}Тестовые сертификаты GUEST/TEST3{{else}}Сертификаты для тестового и боевого контуров{{end}}, дистрибутив ИШ, доступ к личному кабинету УЦ НРД
    Команда ЛК (ESIA Finance)Базовый URL ЛК, Basic-auth учётные данные, {{if .DevMode}}очерёдность подключения (сначала эмулятор, потом реальный ЛК){{else}}окружения ЛК (тест и бой){{end}}
    Команда FansyКонтракт docs/fansy-contract/v1/, SLA, окна обслуживания, IP-allowlist
    Брокеры-контрагенты MOSTБКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed
    Брокеры-контрагенты MOSTБКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971){{if .DevMode}} — уже в seed{{end}}
    diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html index ee83243..433adf0 100644 --- a/internal/lkgateway/web/templates/admin_home.html +++ b/internal/lkgateway/web/templates/admin_home.html @@ -13,8 +13,8 @@ {{end}} -{{if .LKUrl}} -{{/* ===== Первичный тест интеграции с ЛК ===== */}} +{{if and .DevMode .LKUrl}} +{{/* ===== Первичный тест интеграции с ЛК (режим разработки) ===== */}}
    @@ -29,10 +29,11 @@ {{/* ===== Крупные плитки задач ===== */}} @@ -120,7 +121,8 @@
    {{end}} -{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД ===== */}} +{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД (режим разработки) ===== */}} +{{if .DevMode}} {{end}} +{{end}} diff --git a/internal/lkgateway/web/templates/admin_incoming.html b/internal/lkgateway/web/templates/admin_incoming.html index 44a19a8..d5d46ee 100644 --- a/internal/lkgateway/web/templates/admin_incoming.html +++ b/internal/lkgateway/web/templates/admin_incoming.html @@ -1,7 +1,7 @@ {{define "content"}}

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

    - Запросы на перевод, где контрагент (или робот в сценарии 3333) — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге. + Запросы на перевод, где контрагент{{if .DevMode}} (или робот в сценарии 3333){{end}} — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге.
    @@ -16,14 +16,14 @@ {{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}} + {{$s := inStatus .}}{{$s.Text}}{{if $s.Outcome}} {{$s.Outcome}}{{end}}{{if .DepoSummary}}
    сверка: {{.DepoSummary}}{{end}}
    {{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} → {{end}} {{else}} -

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

    +

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

    {{end}}

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

    diff --git a/internal/lkgateway/web/templates/admin_incoming_detail.html b/internal/lkgateway/web/templates/admin_incoming_detail.html index 41f79fe..f20083b 100644 --- a/internal/lkgateway/web/templates/admin_incoming_detail.html +++ b/internal/lkgateway/web/templates/admin_incoming_detail.html @@ -44,7 +44,7 @@ .dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
    -

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

    +

    Сверка с DEPO — {{if .DevMode}}эмуляция ответа{{else}}рекомендация по ответу{{end}}

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

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

    @@ -73,7 +73,7 @@

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

    {{if $answered}} -

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

    +

    Решение уже отправлено {{if .DevMode}}роботу{{else}}контрагенту через НРД{{end}}. Ниже — что было выбрано по каждой бумаге.

    @@ -89,8 +89,9 @@
    ReferenceIdКод НРД / ISINКол-воСчёт зачисленияРешение
    {{else}} -

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

    +

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

    + {{if .DevMode}}{{/* подсказки по сценариям робота НРД — только в режиме разработки */}}
    +{{end}}
    + +{{if .Flash}}
    {{.Flash}}
    {{end}} + +{{/* ======================= СПИСОК ======================= */}} +{{if eq .Mode "list"}} +
    +

    Ручной ввод M2M

    +
    + Инструкция + {{if can "operate"}}+ Новое поручение{{end}} +
    +
    +

    Исходящий перевод ценных бумаг через MOEX МОСТ по бумажному поручению клиента. Сотрудник вводит поручение так, как клиент заполнил бы заявку в личном кабинете, сверяет его с учётной и торговой системами, проставляет обязательные отметки и отправляет. Дальше перевод идёт тем же путём, что и заявка из личного кабинета: ответ контрагента, поручение 16, отчёт НРД об исполнении.

    +
    + {{if .List}} +
    + + + {{range .List}} + + + + + + + + + + {{end}} + +
    №Поручение клиентаКлиентБумагиПринимающий депозитарийСостояние
    {{.O.Number}}№ {{.O.PaperNo}} от {{.O.PaperDate}}{{.O.FullName}}{{len .O.Securities}}{{if .O.RecvName}}{{.O.RecvName}}{{else}}ИНН {{.O.RecvINN}}{{end}}{{.StateRu}}открыть →
    + {{else}} +

    Поручений ещё нет. Нажмите «Новое поручение», когда клиент принесёт поручение на перевод.

    + {{end}} +
    +{{end}} + + +{{if eq .Mode "edit"}} +

    ← К списку

    +
    +

    Новое поручение на перевод M2M

    +

    Заполните поля по бумажному поручению клиента. После сохранения выполните сверку с учётной системой и проставьте обязательные отметки — только после этого поручение можно отправить.

    + {{template "manualForm" .}} +
    +{{end}} + +{{/* ======================= КАРТОЧКА ======================= */}} +{{if or (eq .Mode "card") (eq .Mode "print")}} +{{$o := .O}} + + +
    +
    +

    {{if eq .Mode "print"}}Лист сверки поручения на перевод ценных бумаг (MOEX МОСТ){{else}}Поручение {{$o.Number}}{{end}}

    + {{if eq .Progress.Outcome "done"}}исполнено{{else if eq .Progress.Outcome "rejected"}}{{if eq $o.Status "declined"}}отклонено депозитарием{{else}}отказ{{end}}{{else if eq $o.Status "sent"}}в работе{{else}}черновик{{end}} +
    +
    +
    Номер в системе
    {{$o.Number}} · введено {{msk $o.CreatedAt}}{{if $o.CreatedBy}} · {{$o.CreatedBy}}{{end}}
    +
    Поручение клиента
    № {{$o.PaperNo}} от {{$o.PaperDate}} · {{$o.ReceivedVia}}
    +
    Клиент
    {{$o.FullName}}, дата рождения {{$o.BirthDate}}
    +
    Документ
    код {{$o.DocType}} · {{$o.DocSeries}} № {{$o.DocNumber}}{{if $o.DocIssueDate}} · выдан {{$o.DocIssueDate}}{{end}}{{if $o.DocIssuer}} · {{$o.DocIssuer}}{{end}}
    +
    Счёт депо / договор
    {{$o.Account}}{{if $o.Contract}} · договор {{$o.Contract}}{{end}}{{if $o.QuikCode}} · код в торговой системе {{$o.QuikCode}}{{end}}
    +
    Принимающий депозитарий
    {{$o.RecvName}} · ИНН {{$o.RecvINN}}{{if $o.RecvCode}} · {{$o.RecvCode}}{{end}}{{if $o.RecvAccount}} · счёт клиента {{$o.RecvAccount}}{{end}}
    +
    Место расчётов
    {{if eq $o.SettlePlace "spb"}}СПБ Банк{{else}}НРД{{end}}{{if $o.CostInfo}} · со сведениями о расходах{{end}}
    +
    Бумаги
    {{range $o.Securities}}
    {{.ISIN}}{{if .Code}} ({{.Code}}){{end}}{{if .Name}} — {{.Name}}{{end}} × {{.Qty}}
    {{end}}
    + {{if $o.Comment}}
    Комментарий
    {{$o.Comment}}
    {{end}} + {{if $o.GUID}}
    Идентификатор перевода
    {{$o.GUID}}, отправлено {{msk $o.SentAt}} · {{$o.SentBy}}
    {{end}} +
    +
    + +{{if and (eq $o.Status "draft") (eq .Mode "card")}} + {{if $o.SendErr}}
    Последняя попытка отправки не удалась: {{$o.SendErr}}
    {{end}} +
    +

    Реквизиты

    + {{if .Problems}}

    Нужно исправить:

      {{range .Problems}}
    • {{.}}
    • {{end}}
    {{end}} +
    Изменить данные поручения +
    {{template "manualForm" .}}
    +
    +
    +{{end}} + +{{/* ---- Сверка с учётной системой ---- */}} +{{if or $o.Depo (and (eq $o.Status "draft") .DepoOn)}} +
    +
    +

    Сверка с учётной системой (DEPO)

    + {{if and (eq $o.Status "draft") .DepoOn (eq $.Mode "card")}} +
    + + +
    {{end}} +
    + {{with $o.Depo}} +

    {{msk .At}}{{if .By}} · {{.By}}{{end}} · + {{if not .Ran}}не выполнена: {{.Err}}{{else if eq .Recommend "confirm"}}{{.Summary}}{{else}}{{.Summary}}{{end}}

    + {{if $.DepoStale}}

    Данные поручения изменены после сверки — выполните сверку заново.

    {{end}} + {{if .Ran}}

    Инвестор в DEPO: {{if .Investor}}{{.Investor}}{{else}}не найден{{end}} · счёт: {{if .Account}}{{.Account}}{{else}}—{{end}}

    {{end}} + {{range .Secs}} +
    {{.Security}} — {{if eq .Recommend "confirm"}}можно списать{{else}}нельзя{{if .RejectCode}} ({{.RejectCode}}){{end}}{{end}}
    +
    + {{range .Checks}}{{end}} +
    {{if not .OK}}✕{{else if .Warn}}!{{else}}✓{{end}}{{.Name}}{{.Detail}}
    + {{end}} + {{else}} +

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

    + {{end}} +
    +{{end}} +{{if and (eq $o.Status "draft") (not .DepoOn)}} +

    Сверка с учётной системой

    Учётная система депозитария не подключена к bj — сверку выполните вручную и подтвердите отметками ниже.

    +{{end}} + +{{/* ---- Обязательные отметки ---- */}} +{{if or (eq $o.Status "draft") $o.Checks}} +
    +

    Обязательные отметки перед отправкой

    + {{$editable := and (eq $o.Status "draft") (eq .Mode "card") (can "operate")}} +
    + {{$grp := ""}} + {{range .PreChecks}} + {{if ne .Group $grp}}
    {{.Group}}
    {{$grp = .Group}}{{end}} + {{$m := index $o.Checks .Key}} + + {{end}} +
    Торговая система (QUIK)
    + + + + {{if and $editable (ne $o.QuikMode "manual")}}{{end}} +
    +
    +{{end}} + +{{/* ---- Отправка ---- */}} +{{if and (eq $o.Status "draft") (eq .Mode "card")}} +
    +

    Отправка в НРД

    + {{if .Blockers}} +

    Отправить пока нельзя:

    +
      {{range .Blockers}}
    • {{.}}
    • {{end}}
    + {{else}} +

    Все проверки и отметки пройдены. Поручение уйдёт в НРД тем же путём, что и заявка из личного кабинета{{if eq $o.QuikMode "auto"}}, бумаги будут заблокированы в торговой системе перед отправкой{{end}}.

    + {{end}} + {{if can "operate"}} +
    +
    + +
    +
    Отклонить поручение… +
    + + +
    +
    +
    + {{end}} +
    +{{end}} + +{{/* ---- Ход исполнения ---- */}} +{{if ne $o.Status "draft"}} +
    +

    Ход исполнения

    +
      + {{range $i, $s := .Progress.Steps}} +
    • {{if eq $s.State "done"}}✓{{else if eq $s.State "fail"}}✕{{else}}{{add $i 1}}{{end}} +
      {{$s.Title}}
      {{if not $s.At.IsZero}}{{msk $s.At}}{{end}}{{if and (not $s.At.IsZero) $s.Detail}} · {{end}}{{$s.Detail}}
    • + {{end}} +
    + {{with .Progress.Depo}} +
    + {{if and (not .Submitted) (can "operate")}} +
    + + +
    + {{if $.Auto}}Автоматический режим включён — bj подаст поручение сам в течение минуты.{{end}} + {{end}} + Черновик 16 (ZIP) + {{if .Submitted}}Поданное поручение 16{{end}} + {{if .Done}}Документы НРД (ZIP + PDF){{end}} +
    + {{end}} + {{if .Progress.Quik}} +
    Торговая система
    +
    + {{range .Progress.Quik}}{{end}} +
    {{.ISIN}} × {{.Qty}}{{.StateRu}}{{if .InstrNo}}поручение № {{.InstrNo}}{{end}}{{if .Err}} · {{.Err}}{{end}}
    + {{else if eq $o.QuikMode "manual"}}

    Торговая система: бумаги заблокированы вручную, поручение № {{$o.QuikInstr}}.

    + {{else if eq $o.QuikMode "none"}}

    Торговая система: блокировка не требовалась.

    {{end}} +
    + +{{/* ---- Отметки после ответа ---- */}} +{{if .Progress.Outcome}} +
    +

    {{if eq .Progress.Outcome "done"}}После исполнения{{else}}После отказа{{end}}

    +
    + {{range .PostChecks}}{{if and (eq .When $.Progress.Outcome) (or (not .Quik) (eq $o.QuikMode "manual"))}} + {{$m := index $o.Post .Key}} + + + {{end}}{{end}} +
    + {{if and (eq .Progress.Outcome "rejected") (eq $o.QuikMode "auto")}}

    Блокировку в торговой системе bj снимает автоматически — состояние видно выше.

    {{end}} +
    +{{end}} +{{end}} + +{{if eq .Mode "print"}} +
    +
    Сотрудник депозитария (ФИО, подпись)
    +
    Контролёр (ФИО, подпись)
    +
    +

    Сформировано {{.Now}}.

    + +{{end}} + + +{{end}} +{{end}} + +{{/* ======================= ФОРМА (новое / черновик) ======================= */}} +{{define "manualForm"}} +
    + +
    1. Основание
    +
    +
    +
    +
    +
    +
    + +
    2. Клиент (как в поручении и анкете депонента)
    +
    +
    +
    +
    +
    +
    {{if .DepoOn}}{{end}}
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    3. Ценные бумаги
    +
    ISINКод НРДНаименованиеКоличество
    +
    + {{range .O.Securities}} +
    + {{end}} +
    + +

    Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.

    + +
    4. Принимающая сторона
    +
    +
    + + {{if not .Handbook}}Справочник участников ещё не получен от НРД — введите ИНН вручную.{{end}}
    +
    +
    +
    +
    +
    +
    +
    +

    Бумаги списываются с нашего места хранения в НРД: {{if .NSD.DeponentCode}}депонент {{.NSD.DeponentCode}}, счёт {{.NSD.AccountID}}, раздел {{.NSD.SectionID}}{{else}}реквизиты не заданы в настройках НРД{{end}}.

    + +
    5. Комментарий
    +
    + +
    + + {{if .DepoOn}}{{end}} + К списку +
    +
    + + +{{end}} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 55fef08..a234232 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -13,7 +13,7 @@ - + @@ -195,7 +195,7 @@ {{template "verline" (.Versions.Get "quik-mnp")}}

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

    -

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

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