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("
Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета #M2MTR, канал TEST3.
Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета #M2MTR.
{{.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 @@
В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M или при прогоне «Тестирование M2M → выгрузка истории в DEPO».
+В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M{{if .DevMode}} или при прогоне «Тестирование M2M → выгрузка истории в DEPO»{{end}}.
{{end}}Запросите его у НРД: Настройка → Тесты → «Запросить справочник у НРД». Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.
+Запросите его у НРД: Настройка → Диагностика → «Запросить справочник у НРД». Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.
Установка АПК «Валидата Клиент 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), порядок согласования.
+Исходящий перевод по бумажному поручению клиента: ввод, сверка с учётной и торговой системами, обязательные отметки, отправка, поручение 16, документы НРД.
Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.
+Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → {{if .DevMode}}робот-автотест{{else}}депозитарий-контрагент{{end}}. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.
MC0012500000 в TEST3Только от УЦ Московской Биржи (ca.moex.com). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).
Оптимистично — 1-2 недели (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — 3-4 недели. На нашей стороне всё уже готово; задержка только во внешних шагах.
+ {{else}} +Включать нужно один вариант, иначе процедура будет вызываться параллельно из двух мест. Просьба к разработчику: сделать FTV_M2M_JOB безопасной при повторном/параллельном вызове (обработка только строк со STATE='TODO', блокировка строки на время обработки) — тогда случайное двойное включение не навредит.
Кнопкой «Выгрузить историю в DEPO» на вкладке «Тестирование» bj записал в трансферные таблицы все переводы, проведённые на TEST3 (робот НРД и банк Зенит): 78 переводов, 252 события с реальными датами. Джоба bj2depo создала по каждому документ в DEPO (D_CLISER_M2M + строки), сама определила контрагента, депонента, бумагу и место хранения. Что при этом выяснилось:
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 без действий.Так 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 @@
- При запуске реального ЛК эмулятор остаётся как QA-инструмент
+{{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» нужен, когда клиент подаёт поручение на перевод ценных бумаг на бумаге (в офисе, через представителя, почтой). Сотрудник депозитария вводит поручение в систему так, как клиент заполнил бы заявку в личном кабинете. После отправки перевод идёт тем же путём, что и заявка из личного кабинета, с той же автоматизацией.
+Раздел работает только для исходящих переводов, когда бумаги списываются у нас. Входящие переводы приходят от контрагентов и обрабатываются в разделе «Журнал → Входящие».
+
+Кто работает в разделе
+
+ - Просматривать поручения могут все пользователи с правом просмотра.
+ - Вводить, сверять, отмечать и отправлять поручения может пользователь с правом «Операции».
+ - Каждая отметка сохраняет, кто и когда её поставил. Эти данные попадают в лист сверки для печати.
+
+
+Порядок работы
+
+ - Примите поручение. Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.
+ - Создайте поручение в системе: «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).
+ - Найдите клиента в учётной системе кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.
+ - Выполните сверку с DEPO кнопкой «Сохранить и сверить с DEPO». Система проверяет:
+
+ - клиента по документу, ФИО и дате рождения;
+ - что счёт принадлежит клиенту, открыт и не является ИИС;
+ - что выпуск российский, а бумага есть в справочнике и принята на обслуживание;
+ - что остаток на торговом разделе достаточен и бумаги не заблокированы;
+ - место хранения в НРД;
+ - ограничения для квалифицированных инвесторов (как предупреждение).
+
+ Это те же проверки, что и для заявок из личного кабинета. Если сверка не пройдена, отправить поручение нельзя.
+ - Проставьте обязательные отметки. Каждая отметка подтверждает, что вы лично проверили пункт в учётной системе депозитария. Выберите, как заблокированы бумаги в торговой системе.
+ - Отправьте поручение кнопкой «Отправить в НРД». Кнопка доступна, только когда заполнены все реквизиты, сверка пройдена и все отметки проставлены. Над кнопкой показано, чего не хватает.
+ - Следите за ходом исполнения в карточке поручения (подробнее — ниже).
+ - Закройте поручение отметками после ответа: проведено ли списание в торговой и учётной системах, уведомлён ли клиент, есть ли отметка на бумажном поручении.
+ - Распечатайте лист сверки («Лист сверки для печати») и приложите его к бумажному поручению.
+
+
+Поля поручения
+
+ Номер и дата поручения Реквизиты бумажного поручения клиента (входящий номер депозитария).
+ Как получено Способ получения: лично, через представителя, почтой, курьером, через систему электронного документооборота.
+ Вид документа, серия, номер Документ, удостоверяющий личность, по справочнику НРД. Для паспорта РФ (код 21) серия — 4 цифры, номер — 6 цифр. Принимающий депозитарий ищет клиента по этим данным, поэтому они должны точно совпадать с анкетой.
+ ФИО, дата рождения Как в документе. Дата рождения сверяется с анкетой депонента.
+ Счёт депо клиента Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.
+ Договор, код в торговой системе Для сведения и поиска клиента в торговой системе.
+ Ценные бумаги ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.
+ Принимающий депозитарий Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.
+ Счёт у принимающего Для сведения: принимающий депозитарий сам находит клиента по документу.
+ Место расчётов НРД или СПБ Банк — как в поручении клиента.
+ Сведения о расходах Отметьте, если клиент просит передать сведения о расходах на приобретение бумаг.
+
+Если после сверки или отметок изменить данные поручения, сверка и отметки сбросятся: их нужно пройти заново.
+
+Блокировка в торговой системе
+
+ - Заблокировать автоматически при отправке. Вариант доступен, если в настройках включён автоматический цикл в модуле неторговых поручений QUIK. bj регистрирует вывод бумаг, и лимит уменьшается. По исполнению bj подписывает списание, а при отказе снимает блокировку.
+ - Заблокированы вручную. Сотрудник сам регистрирует поручение на вывод в торговой системе и указывает его номер. После ответа сотрудник отмечает окончательное списание или снятие блокировки.
+ - Не требуется. Выберите этот вариант, если бумаги не учитываются в торговой системе.
+
+
+Ход исполнения
+
+ - Пакет отправлен в НРД. Сервис MOEX МОСТ проверяет формат и присылает квитанцию. Если есть ошибка, поручение завершается отказом с кодом, а блокировка снимается.
+ - Решение принимающего депозитария. Депозитарий соглашается или отказывает (код отказа виден в карточке). Пакет принимается только целиком.
+ - Черновик поручения 16. После согласия НРД присылает черновик поручения на списание.
+ - Поручение 16 подано. Нажмите «Подать поручение 16 по черновику». Если включён автоматический режим M2M, bj подаёт поручение сам.
+ - Отчёт НРД об исполнении. Когда отчёт (MS010) получен, бумаги списаны. Кнопка «Документы НРД» скачивает архив с квитанциями и отчётом, в том числе в PDF.
+
+Сведения о переводе автоматически передаются в учётную систему депозитария, как для заявок из личного кабинета. Полная техническая информация (пакеты, ответы НРД) — в «Карточке перевода».
+
+Отклонение поручения
+Если поручение нельзя исполнить (например, нет подписи, недостаточно бумаг или депозитарий не участвует в 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 @@
Как проверить, что всё готово
- В bj: Настройка → QUIK МНП → «Проверить подключение». В отчёте должны быть видны процедуры
sp_NewInstrSec, sp_SignInstrSec, cancel_instruction и таблица M2MDepoRecipients.
- - Скажите нам «проверяйте». Мы на согласованном тестовом клиенте регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.
+ - Скажите нам «проверяйте». Мы на согласованном клиенте для проверки регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.
- Признак готовности в самой базе МНП: у типов 100 и 101 снят признак Disabled, у классов INSTROUTM2M / INSTRINM2M есть фирма, инструменты привязаны к классам. Это мы проверяем сами.
@@ -149,7 +149,7 @@
RUROUTM2M / RURINM2M (шаг 3).Основной канал отправки M2M-сообщений в НРД — Web-сервис ONYX через ИШ. ИШ сам подписывает пакеты ЭДО, поэтому в этом канале нам криптография не требуется. Дистрибутив ИШ скачивается с сайта НРД: www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4
Адреса контуров (из DOC/Ссылки для доступа в тестовые контуры.pdf):
| Внешняя сторона | Что согласовать |
|---|---|
| НРД (Национальный расчётный депозитарий) | Тестовые сертификаты 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}} |
Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.
+Переводов ещё нет. {{if .DevMode}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}
{{end}}{{short 8 .GUID}}{{.SenderCode}}{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»).
+Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if .DevMode}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.
{{end}}🔄 Страница обновляется автоматически при появлении нового входящего.
Проверки по регламенту депозитария (инвестор, торговый счёт депо, бумага на обслуживании, договор не ИИС, блокировки). Ответ контрагенту НЕ отправлен — это только рекомендация; она подставлена в форму ниже, проверьте и нажмите «Отправить решение».
@@ -73,7 +73,7 @@Решение уже отправлено роботу. Ниже — что было выбрано по каждой бумаге.
+Решение уже отправлено {{if .DevMode}}роботу{{else}}контрагенту через НРД{{end}}. Ниже — что было выбрано по каждой бумаге.
| ReferenceId | Код НРД / ISIN | Кол-во | Счёт зачисления | Решение |
|---|
По каждой бумаге выберите: принять (зачислим на наш депо-счёт из настроек НРД) или отказать (с кодом отказа). Решение уйдёт роботу одним сообщением M2MTransferDecision.
+По каждой бумаге выберите: принять (зачислим на наш депо-счёт из настроек НРД) или отказать (с кодом отказа). Решение уйдёт {{if .DevMode}}роботу{{else}}контрагенту через НРД{{end}} одним сообщением M2MTransferDecision.
+ {{if .DevMode}}{{/* подсказки по сценариям робота НРД — только в режиме разработки */}}