Раздел «Ручной ввод M2M» по бумажным поручениям; тестовые тексты и имитации убраны из клиентского интерфейса
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 перезапускает Интеграционный шлюз и проверяет, что он
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", "Иной документ"},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. Включается осознанно (флаг/настройка), когда контур готов.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+21
-16
@@ -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("<bj-test ts=\"" + time.Now().UTC().Format(time.RFC3339) + "\">Bridge-and-Join-s sign smoke test</bj-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; возвращает (код, тело).
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{with .Claim.TestResult}}
|
||||
{{if .DevMode}}{{with .Claim.TestResult}}
|
||||
<div class="card" style="background:{{if eq .Status "ok"}}var(--ok-weak){{else if eq .Status "mismatch"}}var(--err-weak){{else if eq .Status "error"}}var(--err-weak){{else}}var(--warn-weak){{end}}">
|
||||
<h2>
|
||||
{{if eq .Status "ok"}}<span style="color:var(--ok)">✓ Тест пройден</span>
|
||||
@@ -73,7 +73,7 @@
|
||||
{{if .Note}}<tr><td class="muted">Вывод</td><td>{{.Note}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Ценные бумаги ({{len .Claim.Securities}})</h2>
|
||||
@@ -235,7 +235,7 @@
|
||||
{{if .Claim.RequestRawXML}}
|
||||
<div class="card">
|
||||
<h2>Отправленный пакет (M2MTransferRequest)</h2>
|
||||
<p class="muted">Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета <code>#M2MTR</code>, канал TEST3.</p>
|
||||
<p class="muted">Точный XML, который bj-server сформировал и ИШ подписал ключом и отправил в НРД. Тип пакета <code>#M2MTR</code>.</p>
|
||||
<a class="btn btn-ok" href="/admin/claims/{{.Claim.ID}}/request.xml" download style="margin-right:8px">⤓ Скачать XML запроса</a>
|
||||
<button type="button" class="btn" onclick="copyReq(this)">Скопировать</button>
|
||||
<pre id="raw-request" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.RequestRawXML}}</pre>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{{else if not .Error}}
|
||||
<p class="muted">В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M или при прогоне «Тестирование M2M → выгрузка истории в DEPO».</p>
|
||||
<p class="muted">В трансферных таблицах пока нет переводов. Они появятся при входящем/исходящем переводе M2M{{if .DevMode}} или при прогоне «Тестирование M2M → выгрузка истории в DEPO»{{end}}.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{{if not .Participants}}
|
||||
<div style="margin-top:12px;padding:14px;background:var(--warn-weak,#f6eed8);border-radius:10px">
|
||||
<strong>Справочник ещё не загружен.</strong>
|
||||
<p class="muted" style="margin:6px 0 0">Запросите его у НРД: <a href="/admin/setup">Настройка → Тесты → «Запросить справочник у НРД»</a>. Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.</p>
|
||||
<p class="muted" style="margin:6px 0 0">Запросите его у НРД: <a href="/admin/setup">Настройка → Диагностика → «Запросить справочник у НРД»</a>. Ответ придёт во входящие ИШ (~30–60 сек) и появится здесь.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -20,13 +20,19 @@
|
||||
<a href="/admin/help/crypto" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Криптография (Валидата) →</h2>
|
||||
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
|
||||
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, проверка подписи и квитанций НРД.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/systems" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Внешние системы →</h2>
|
||||
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
||||
<p class="muted">ИШ НРД ({{if .DevMode}}профили GUEST/TEST3/PROD{{else}}тестовый и боевой контуры{{end}}), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/manual" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Ручной ввод M2M →</h2>
|
||||
<p class="muted">Исходящий перевод по бумажному поручению клиента: ввод, сверка с учётной и торговой системами, обязательные отметки, отправка, поручение 16, документы НРД.</p>
|
||||
</div>
|
||||
</a>
|
||||
{{if .DevMode}}<a href="/admin/help/robot" style="text-decoration:none">
|
||||
@@ -38,7 +44,7 @@
|
||||
<a href="/admin/help/architecture" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
|
||||
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
|
||||
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → {{if .DevMode}}робот-автотест{{else}}депозитарий-контрагент{{end}}. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/help/fancy-events" style="text-decoration:none">
|
||||
|
||||
@@ -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}} │ • реальные депозитарии │
|
||||
│ • биржевые системы │
|
||||
└────────────────────────────┘
|
||||
</pre>
|
||||
@@ -64,7 +64,7 @@
|
||||
<td>наша</td>
|
||||
<td>Astra Linux SE 1.7 / Linux</td>
|
||||
<td>АПК «Валидата Клиент L» (PKCS#11)</td>
|
||||
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
|
||||
<td>Стейт-машина, журнал в БД, веб-админка{{if .DevMode}}, lk-emulator{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>ИШ (igate)</strong></td>
|
||||
@@ -80,13 +80,13 @@
|
||||
<td>—</td>
|
||||
<td>Web-сервис НРД — принимает пакеты от ИШ всех клиентов</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{{if .DevMode}}<tr>
|
||||
<td><strong>Робот-автотест</strong></td>
|
||||
<td>НРД</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>Контрагент-эмулятор внутри НРД. Адресуется кодом <code>MC0012500000</code> в TEST3</td>
|
||||
</tr>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -109,6 +109,7 @@
|
||||
<h3>Q: Какой сертификат нужен?</h3>
|
||||
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
|
||||
|
||||
{{if .DevMode}}
|
||||
<h3>Q: Что делать, чтобы протестировать на роботе НРД на TEST3?</h3>
|
||||
<ol>
|
||||
<li>Получить сертификат УЦ МБ для нашей организации.</li>
|
||||
@@ -121,8 +122,18 @@
|
||||
|
||||
<h3>Q: Сколько времени нужно от старта подключения до прогона на TEST3?</h3>
|
||||
<p>Оптимистично — <strong>1-2 недели</strong> (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — <strong>3-4 недели</strong>. На нашей стороне всё уже готово; задержка только во внешних шагах.</p>
|
||||
{{else}}
|
||||
<h3>Q: Что нужно, чтобы начать обмен с НРД?</h3>
|
||||
<ol>
|
||||
<li>Получить сертификат УЦ МБ для организации.</li>
|
||||
<li>Оформить в НРД подключение к ЭДО и сервису MOEX МОСТ (M2M), получить доступ к нужному контуру.</li>
|
||||
<li>Поднять Astra Linux ВМ, поставить ИШ + Валидату, импортировать сертификат.</li>
|
||||
<li>В <a href="/admin/setup">Настройке</a> → «НРД» указать профиль, URL ИШ и ключевой контейнер, затем проверить подключения на странице <a href="/admin/status">«Статус»</a>.</li>
|
||||
</ol>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .DevMode}}
|
||||
<div class="card">
|
||||
<h2>Что у нас уже готово</h2>
|
||||
<ul>
|
||||
@@ -134,3 +145,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="#actions">Действия джобы по событиям</a>
|
||||
<a href="#handbook">Справочник участников</a>
|
||||
<a href="#runmode">Кто запускает обработку</a>
|
||||
<a href="#replay">Выгрузка истории тестов</a>
|
||||
{{if .DevMode}}<a href="#replay">Выгрузка истории тестов</a>{{end}}
|
||||
<div class="toc-group">Практика</div>
|
||||
<a href="#sql">Примеры SQL</a>
|
||||
<a href="#questions">Открытые вопросы</a>
|
||||
@@ -184,6 +184,7 @@ ORDER BY e.ID;</pre>
|
||||
<p class="muted">Включать нужно один вариант, иначе процедура будет вызываться параллельно из двух мест. Просьба к разработчику: сделать <code>FTV_M2M_JOB</code> безопасной при повторном/параллельном вызове (обработка только строк со <code>STATE='TODO'</code>, блокировка строки на время обработки) — тогда случайное двойное включение не навредит.</p>
|
||||
|
||||
|
||||
{{if .DevMode}}{{/* отчёт о выгрузке истории тестирования — только в режиме разработки */}}
|
||||
<h2 id="replay">Результат выгрузки истории тестов (14.09.2026)</h2>
|
||||
<p>Кнопкой «Выгрузить историю в DEPO» на вкладке «Тестирование» bj записал в трансферные таблицы все переводы, проведённые на TEST3 (робот НРД и банк Зенит): 78 переводов, 252 события с реальными датами. Джоба bj2depo создала по каждому документ в DEPO (D_CLISER_M2M + строки), сама определила контрагента, депонента, бумагу и место хранения. Что при этом выяснилось:</p>
|
||||
<ul>
|
||||
@@ -192,14 +193,15 @@ ORDER BY e.ID;</pre>
|
||||
<li>После обработки событий статусы в DEPO не меняются: у всех документов <code>DOC_STATUS=0</code>, у строк <code>M2M_STATUS=0</code>, <code>DEP_STATUS=0</code>. <strong>Просьба согласовать отображение</strong>, например: <code>BJ_CP_REJECT</code> / <code>BJ_DECISION_REJECT</code> → строка «отказ» с кодом в M2M_TEXT; <code>BJ_CP_CONFIRM</code> / <code>BJ_DECISION_CONFIRM</code> → «принято»; <code>BJ_ORDER_ACCEPTED</code> / <code>BJ_EXECUTED</code> → документ «исполнен»; <code>BJ_SERVICE_ERROR</code> / <code>BJ_CANCELLED</code> → документ «отклонён / отменён». Тогда историю тестов можно будет просмотреть в DEPO с настоящими статусами.</li>
|
||||
<li>Событие <code>BJ_PING</code> сейчас помечается ERROR — достаточно помечать SUCCESS без действий.</li>
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<h2 id="sql">Примеры SQL</h2>
|
||||
<p>Так bj заведёт входящий перевод (одна транзакция):</p>
|
||||
<pre class="copyable">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;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<pre>curl -u "lk-user:lk-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
http://10.10.10.22:8080/api/v1/back_office/claims/</pre>
|
||||
<p class="muted">На M2 (дев-стенд) аутентификация отключена; включится на M3 — параллельно с подключением реального ЛК.</p>
|
||||
{{if .DevMode}}<p class="muted">На M2 (дев-стенд) аутентификация отключена; включится на M3 — параллельно с подключением реального ЛК.</p>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -75,6 +75,7 @@
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
{{if .DevMode}}
|
||||
<div class="card">
|
||||
<h2>7. Эмулятор ЛК для тестов</h2>
|
||||
<p>На дев-стенде доступен <strong>lk-emulator</strong> на порту 8083 — имитация ЛК. Он сам регистрирует свой URL в bj-server как callback-приёмник.</p>
|
||||
@@ -84,10 +85,11 @@
|
||||
<li>При запуске реального ЛК эмулятор остаётся как QA-инструмент</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>8. Подписание заявления</h2>
|
||||
<h2>{{if .DevMode}}8{{else}}7{{end}}. Подписание заявления</h2>
|
||||
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
|
||||
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
|
||||
{{if .DevMode}}<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{{define "content"}}
|
||||
<style>
|
||||
.hm{max-width:880px}
|
||||
.hm h2{margin-top:26px}
|
||||
.hm ol li,.hm ul li{margin:5px 0}
|
||||
.hm .tw table td:first-child{white-space:nowrap;font-weight:600}
|
||||
</style>
|
||||
<div class="hm">
|
||||
<p style="margin:0 0 12px"><a href="/admin/help" class="btn">← Справка</a> <a href="/admin/manual" class="btn btn-secondary">К разделу</a></p>
|
||||
<h1>Ручной ввод M2M: инструкция</h1>
|
||||
<p>Раздел «Ручной ввод M2M» нужен, когда клиент подаёт поручение на перевод ценных бумаг <b>на бумаге</b> (в офисе, через представителя, почтой). Сотрудник депозитария вводит поручение в систему так, как клиент заполнил бы заявку в личном кабинете. После отправки перевод идёт тем же путём, что и заявка из личного кабинета, с той же автоматизацией.</p>
|
||||
<p>Раздел работает только для <b>исходящих</b> переводов, когда бумаги списываются у нас. Входящие переводы приходят от контрагентов и обрабатываются в разделе «Журнал → Входящие».</p>
|
||||
|
||||
<h2>Кто работает в разделе</h2>
|
||||
<ul>
|
||||
<li>Просматривать поручения могут все пользователи с правом просмотра.</li>
|
||||
<li>Вводить, сверять, отмечать и отправлять поручения может пользователь с правом «Операции».</li>
|
||||
<li>Каждая отметка сохраняет, кто и когда её поставил. Эти данные попадают в лист сверки для печати.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Порядок работы</h2>
|
||||
<ol>
|
||||
<li><b>Примите поручение.</b> Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.</li>
|
||||
<li><b>Создайте поручение в системе:</b> «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).</li>
|
||||
<li><b>Найдите клиента в учётной системе</b> кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.</li>
|
||||
<li><b>Выполните сверку с DEPO</b> кнопкой «Сохранить и сверить с DEPO». Система проверяет:
|
||||
<ul>
|
||||
<li>клиента по документу, ФИО и дате рождения;</li>
|
||||
<li>что счёт принадлежит клиенту, открыт и не является ИИС;</li>
|
||||
<li>что выпуск российский, а бумага есть в справочнике и принята на обслуживание;</li>
|
||||
<li>что остаток на торговом разделе достаточен и бумаги не заблокированы;</li>
|
||||
<li>место хранения в НРД;</li>
|
||||
<li>ограничения для квалифицированных инвесторов (как предупреждение).</li>
|
||||
</ul>
|
||||
Это те же проверки, что и для заявок из личного кабинета. Если сверка не пройдена, отправить поручение нельзя.</li>
|
||||
<li><b>Проставьте обязательные отметки.</b> Каждая отметка подтверждает, что вы лично проверили пункт в учётной системе депозитария. Выберите, как заблокированы бумаги в торговой системе.</li>
|
||||
<li><b>Отправьте поручение</b> кнопкой «Отправить в НРД». Кнопка доступна, только когда заполнены все реквизиты, сверка пройдена и все отметки проставлены. Над кнопкой показано, чего не хватает.</li>
|
||||
<li><b>Следите за ходом исполнения</b> в карточке поручения (подробнее — ниже).</li>
|
||||
<li><b>Закройте поручение отметками после ответа:</b> проведено ли списание в торговой и учётной системах, уведомлён ли клиент, есть ли отметка на бумажном поручении.</li>
|
||||
<li><b>Распечатайте лист сверки</b> («Лист сверки для печати») и приложите его к бумажному поручению.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Поля поручения</h2>
|
||||
<div class="tw"><table><tbody>
|
||||
<tr><td>Номер и дата поручения</td><td>Реквизиты бумажного поручения клиента (входящий номер депозитария).</td></tr>
|
||||
<tr><td>Как получено</td><td>Способ получения: лично, через представителя, почтой, курьером, через систему электронного документооборота.</td></tr>
|
||||
<tr><td>Вид документа, серия, номер</td><td>Документ, удостоверяющий личность, по справочнику НРД. Для паспорта РФ (код 21) серия — 4 цифры, номер — 6 цифр. Принимающий депозитарий ищет клиента по этим данным, поэтому они должны точно совпадать с анкетой.</td></tr>
|
||||
<tr><td>ФИО, дата рождения</td><td>Как в документе. Дата рождения сверяется с анкетой депонента.</td></tr>
|
||||
<tr><td>Счёт депо клиента</td><td>Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.</td></tr>
|
||||
<tr><td>Договор, код в торговой системе</td><td>Для сведения и поиска клиента в торговой системе.</td></tr>
|
||||
<tr><td>Ценные бумаги</td><td>ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.</td></tr>
|
||||
<tr><td>Принимающий депозитарий</td><td>Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.</td></tr>
|
||||
<tr><td>Счёт у принимающего</td><td>Для сведения: принимающий депозитарий сам находит клиента по документу.</td></tr>
|
||||
<tr><td>Место расчётов</td><td>НРД или СПБ Банк — как в поручении клиента.</td></tr>
|
||||
<tr><td>Сведения о расходах</td><td>Отметьте, если клиент просит передать сведения о расходах на приобретение бумаг.</td></tr>
|
||||
</tbody></table></div>
|
||||
<p>Если после сверки или отметок изменить данные поручения, сверка и отметки сбросятся: их нужно пройти заново.</p>
|
||||
|
||||
<h2>Блокировка в торговой системе</h2>
|
||||
<ul>
|
||||
<li><b>Заблокировать автоматически при отправке.</b> Вариант доступен, если в настройках включён автоматический цикл в модуле неторговых поручений QUIK. bj регистрирует вывод бумаг, и лимит уменьшается. По исполнению bj подписывает списание, а при отказе снимает блокировку.</li>
|
||||
<li><b>Заблокированы вручную.</b> Сотрудник сам регистрирует поручение на вывод в торговой системе и указывает его номер. После ответа сотрудник отмечает окончательное списание или снятие блокировки.</li>
|
||||
<li><b>Не требуется.</b> Выберите этот вариант, если бумаги не учитываются в торговой системе.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Ход исполнения</h2>
|
||||
<ol>
|
||||
<li><b>Пакет отправлен в НРД.</b> Сервис MOEX МОСТ проверяет формат и присылает квитанцию. Если есть ошибка, поручение завершается отказом с кодом, а блокировка снимается.</li>
|
||||
<li><b>Решение принимающего депозитария.</b> Депозитарий соглашается или отказывает (код отказа виден в карточке). Пакет принимается только целиком.</li>
|
||||
<li><b>Черновик поручения 16.</b> После согласия НРД присылает черновик поручения на списание.</li>
|
||||
<li><b>Поручение 16 подано.</b> Нажмите «Подать поручение 16 по черновику». Если включён автоматический режим M2M, bj подаёт поручение сам.</li>
|
||||
<li><b>Отчёт НРД об исполнении.</b> Когда отчёт (MS010) получен, бумаги списаны. Кнопка «Документы НРД» скачивает архив с квитанциями и отчётом, в том числе в PDF.</li>
|
||||
</ol>
|
||||
<p>Сведения о переводе автоматически передаются в учётную систему депозитария, как для заявок из личного кабинета. Полная техническая информация (пакеты, ответы НРД) — в «Карточке перевода».</p>
|
||||
|
||||
<h2>Отклонение поручения</h2>
|
||||
<p>Если поручение нельзя исполнить (например, нет подписи, недостаточно бумаг или депозитарий не участвует в M2M), нажмите «Отклонить поручение…» и укажите причину. В НРД ничего не отправляется. Уведомите клиента и сделайте отметку на бумажном поручении.</p>
|
||||
|
||||
<h2>Частые вопросы</h2>
|
||||
<ul>
|
||||
<li><b>Можно ли изменить поручение после отправки?</b> Нет. Если данные неверны, дождитесь отказа или исполнения и введите новое поручение.</li>
|
||||
<li><b>Отправка не удалась.</b> Причина показана в карточке, поручение остаётся черновиком. Устраните причину (например, связь с НРД или торговой системой) и отправьте снова.</li>
|
||||
<li><b>Нет кнопки «Найти в DEPO».</b> Учётная система не подключена к bj. Заполните поля вручную и выполните сверку в учётной системе сами: отметки подтверждают эту проверку.</li>
|
||||
<li><b>Справочник участников пуст.</b> Администратор может запросить справочник в «Настройка → Диагностика», а пока ИНН можно ввести вручную.</li>
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -127,7 +127,7 @@
|
||||
<h2 id="verify">Как проверить, что всё готово</h2>
|
||||
<ol>
|
||||
<li>В bj: <a href="/admin/setup#sec-quik">Настройка → QUIK МНП</a> → «Проверить подключение». В отчёте должны быть видны процедуры <code>sp_NewInstrSec</code>, <code>sp_SignInstrSec</code>, <code>cancel_instruction</code> и таблица <code>M2MDepoRecipients</code>.</li>
|
||||
<li>Скажите нам «проверяйте». Мы на согласованном тестовом клиенте регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.</li>
|
||||
<li>Скажите нам «проверяйте». Мы на согласованном клиенте для проверки регистрируем «Ввод ЦБ (M2M)» на 1 штуку (должно исполниться сразу, позиция +1), затем «Вывод ЦБ (M2M)» на 1 штуку с подписью депозитария (позиция −1). Весь цикл занимает две минуты, клиент видит оба поручения в терминале.</li>
|
||||
<li>Признак готовности в самой базе МНП: у типов 100 и 101 снят признак Disabled, у классов INSTROUTM2M / INSTRINM2M есть фирма, инструменты привязаны к классам. Это мы проверяем сами.</li>
|
||||
</ol>
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
<li>Точные коды инструментов, если они отличаются от <code>RUROUTM2M</code> / <code>RURINM2M</code> (шаг 3).</li>
|
||||
<li>Что типы 100/101 включены и привязаны к классам (шаг 4), права техпользователю выданы (шаг 5).</li>
|
||||
<li>Время перезапуска модуля (шаг 7).</li>
|
||||
<li>Код тестового клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.</li>
|
||||
<li>Код клиента QUIK, на котором разрешено проводить проверку, и что у него есть хотя бы одна бумага на позиции.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="docs">Документы ARQA</h2>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Интеграционный шлюз НРД (ИШ) и контуры тестирования</h2>
|
||||
<h2>1. Интеграционный шлюз НРД (ИШ) и контуры НРД</h2>
|
||||
<p>Основной канал отправки M2M-сообщений в НРД — Web-сервис <strong>ONYX</strong> через ИШ. ИШ сам подписывает пакеты ЭДО, поэтому в этом канале нам криптография не требуется. Дистрибутив ИШ скачивается с сайта НРД: <code>www.nsd.ru/workflow/system/programs/#0-widget-faq-0-4</code></p>
|
||||
<p>Адреса контуров (из <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>):</p>
|
||||
<table>
|
||||
@@ -45,7 +45,7 @@
|
||||
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
|
||||
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
||||
</ul>
|
||||
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
|
||||
{{if .DevMode}}<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>{{else}}<p class="muted">Пока ИШ не настроен, обмен с НРД не выполняется: поручения в НРД не отправляются.</p>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -96,10 +96,10 @@
|
||||
<table>
|
||||
<thead><tr><th>Внешняя сторона</th><th>Что согласовать</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
||||
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
|
||||
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>{{if .DevMode}}Тестовые сертификаты GUEST/TEST3{{else}}Сертификаты для тестового и боевого контуров{{end}}, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
||||
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, {{if .DevMode}}очерёдность подключения (сначала эмулятор, потом реальный ЛК){{else}}окружения ЛК (тест и бой){{end}}</td></tr>
|
||||
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
|
||||
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
|
||||
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971){{if .DevMode}} — уже в seed{{end}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .LKUrl}}
|
||||
{{/* ===== Первичный тест интеграции с ЛК ===== */}}
|
||||
{{if and .DevMode .LKUrl}}
|
||||
{{/* ===== Первичный тест интеграции с ЛК (режим разработки) ===== */}}
|
||||
<div class="card" style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
|
||||
<div style="width:42px;height:42px;border-radius:11px;background:var(--accent-weak);color:var(--accent);display:flex;align-items:center;justify-content:center;flex:none">
|
||||
<svg viewBox="0 0 24 24" width="21" height="21" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
||||
@@ -29,10 +29,11 @@
|
||||
|
||||
{{/* ===== Крупные плитки задач ===== */}}
|
||||
<div class="tiles">
|
||||
<a href="#" onclick="document.getElementById('sim-modal').style.display='flex';return false" class="tile brand">
|
||||
{{/* В режиме разработки — симулятор перевода роботу НРД, у клиента — ручной ввод поручения */}}
|
||||
<a {{if .DevMode}}href="#" onclick="document.getElementById('sim-modal').style.display='flex';return false"{{else}}href="/admin/manual"{{end}} class="tile brand">
|
||||
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h12M12 4l4 4-4 4"/><path d="M20 16H8M12 20l-4-4 4-4"/></svg></span>
|
||||
<span class="t-title">Новый перевод</span>
|
||||
<span class="t-sub">Симулятор: сценарий робота + кол-во бумаг</span>
|
||||
<span class="t-sub">{{if .DevMode}}Симулятор: сценарий робота + кол-во бумаг{{else}}Ручной ввод поручения клиента{{end}}</span>
|
||||
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
|
||||
</a>
|
||||
<a href="/admin/claims" class="tile">
|
||||
@@ -98,7 +99,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
|
||||
<p class="muted" style="margin:0">Переводов ещё нет. {{if .DevMode}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -120,7 +121,8 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД ===== */}}
|
||||
{{/* ===== Модалка-симулятор: тестовый перевод роботу НРД (режим разработки) ===== */}}
|
||||
{{if .DevMode}}
|
||||
<div id="sim-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center"
|
||||
onclick="if(event.target===this)this.style.display='none'">
|
||||
<div style="background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:480px;width:92%;box-shadow:var(--shadow-lg)">
|
||||
@@ -148,3 +150,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<div class="hero">
|
||||
<h1 class="hero-greeting">Входящие переводы</h1>
|
||||
<span class="hero-status">Запросы на перевод, где контрагент (или робот в сценарии 3333) — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге.</span>
|
||||
<span class="hero-status">Запросы на перевод, где контрагент{{if .DevMode}} (или робот в сценарии 3333){{end}} — передающая сторона. Нужно ответить: принять или отказать по каждой бумаге.</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -16,14 +16,14 @@
|
||||
<td><code>{{short 8 .GUID}}</code></td>
|
||||
<td>{{$nm := m2mParty .SenderCode}}{{if not $nm}}{{$nm = m2mParty .SenderINN}}{{end}}{{if $nm}}<strong>{{$nm}}</strong><br>{{end}}<code>{{.SenderCode}}</code>{{if .SenderINN}} · ИНН {{.SenderINN}}{{end}}</td>
|
||||
<td>{{len .Securities}}</td>
|
||||
<td>{{$s := inStatus .}}<span class="badge {{$s.Class}}">{{$s.Text}}</span>{{if $s.Outcome}} <span class="badge {{if eq $s.Outcome "Принято"}}confirmed{{else if eq $s.Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{$s.Outcome}}</span>{{end}}{{if .DepoSummary}}<br><span class="badge {{if eq .DepoRecommend "confirm"}}confirmed{{else if eq .DepoRecommend "reject"}}rejected{{else}}awaiting_decision{{end}}" style="font-size:11px;margin-top:4px" title="Сверка с DEPO — эмуляция ответа, ничего не отправлено">сверка: {{.DepoSummary}}</span>{{end}}</td>
|
||||
<td>{{$s := inStatus .}}<span class="badge {{$s.Class}}">{{$s.Text}}</span>{{if $s.Outcome}} <span class="badge {{if eq $s.Outcome "Принято"}}confirmed{{else if eq $s.Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{$s.Outcome}}</span>{{end}}{{if .DepoSummary}}<br><span class="badge {{if eq .DepoRecommend "confirm"}}confirmed{{else if eq .DepoRecommend "reject"}}rejected{{else}}awaiting_decision{{end}}" style="font-size:11px;margin-top:4px" title="Сверка с DEPO — {{if $.DevMode}}эмуляция ответа{{else}}рекомендация{{end}}, ничего не отправлено">сверка: {{.DepoSummary}}</span>{{end}}</td>
|
||||
<td><a href="/admin/incoming/{{.ID}}">{{if eq .Status "answered"}}просмотр{{else}}ответить{{end}} →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»).</p>
|
||||
<p class="muted">Входящих переводов нет. Они появляются, когда другая сторона инициирует перевод к нам{{if .DevMode}} (напр. робот НРД в сценарии 3333 «Выступить принимающей стороной»){{end}}.</p>
|
||||
{{end}}
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">🔄 Страница обновляется автоматически при появлении нового входящего.</p>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
.dc-rec.mixed{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
|
||||
</style>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<h2 style="margin:0">Сверка с DEPO — эмуляция ответа</h2>
|
||||
<h2 style="margin:0">Сверка с DEPO — {{if .DevMode}}эмуляция ответа{{else}}рекомендация по ответу{{end}}</h2>
|
||||
{{if .Depo.Ran}}<span class="dc-rec {{.Depo.Recommend}}">рекомендуем: {{.Depo.Summary}}</span>{{end}}
|
||||
</div>
|
||||
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Проверки по регламенту депозитария (инвестор, торговый счёт депо, бумага на обслуживании, договор не ИИС, блокировки). <b>Ответ контрагенту НЕ отправлен</b> — это только рекомендация; она подставлена в форму ниже, проверьте и нажмите «Отправить решение».</p>
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="card">
|
||||
<h2>Решение по бумагам ({{len .It.Securities}})</h2>
|
||||
{{if $answered}}
|
||||
<p class="muted">Решение уже отправлено роботу. Ниже — что было выбрано по каждой бумаге.</p>
|
||||
<p class="muted">Решение уже отправлено {{if .DevMode}}роботу{{else}}контрагенту через НРД{{end}}. Ниже — что было выбрано по каждой бумаге.</p>
|
||||
<table>
|
||||
<thead><tr><th>ReferenceId</th><th>Код НРД / ISIN</th><th>Кол-во</th><th>Счёт зачисления</th><th>Решение</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -89,8 +89,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">По каждой бумаге выберите: <b>принять</b> (зачислим на наш депо-счёт из настроек НРД) или <b>отказать</b> (с кодом отказа). Решение уйдёт роботу одним сообщением M2MTransferDecision.</p>
|
||||
<p class="muted">По каждой бумаге выберите: <b>принять</b> (зачислим на наш депо-счёт из настроек НРД) или <b>отказать</b> (с кодом отказа). Решение уйдёт {{if .DevMode}}роботу{{else}}контрагенту через НРД{{end}} одним сообщением M2MTransferDecision.</p>
|
||||
|
||||
{{if .DevMode}}{{/* подсказки по сценариям робота НРД — только в режиме разработки */}}
|
||||
<div style="padding:10px 14px;background:var(--accent-weak,#e7f0fb);border-radius:10px;margin:6px 0 14px">
|
||||
<label style="font-size:13px;font-weight:600">Сценарий тестирования:
|
||||
<select id="m2m-scen" style="margin-left:8px;padding:5px 8px;border-radius:7px;border:1px solid var(--border);background:var(--card);color:var(--text)">
|
||||
@@ -113,6 +114,7 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
<form method="post" action="/admin/incoming/{{.It.ID}}/answer">
|
||||
<table>
|
||||
@@ -140,7 +142,7 @@
|
||||
</table>
|
||||
<p class="muted" style="font-size:12.5px;margin:10px 0 0">Пакет принимается только целиком: при отказе по одной бумаге отказ ставится всем бумагам с тем же кодом (так и будет отправлено).</p>
|
||||
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-ok">Отправить решение роботу</button>
|
||||
<button type="submit" class="btn btn-ok">Отправить решение{{if .DevMode}} роботу{{end}}</button>
|
||||
<button type="button" class="btn" onclick="m2mSetAll('confirm')">Принять все</button>
|
||||
<button type="button" class="btn" onclick="m2mSetAll('reject')">Отказать все</button>
|
||||
</div>
|
||||
@@ -354,17 +356,19 @@
|
||||
{{/* ===== QUIK МНП: зачисление клиенту ===== */}}
|
||||
{{$back := printf "/admin/incoming/%s" .It.ID}}
|
||||
|
||||
{{if .DevMode}}{{/* прогон копии в DEPO — служебный инструмент режима разработки */}}
|
||||
<div class="card">
|
||||
<h2 style="margin:0">Тестовый прогон в DEPO с реальным клиентом</h2>
|
||||
<p class="muted" style="font-size:12.5px;margin:6px 0 8px">Копия этого входящего (те же бумаги и решения) записывается в трансферные таблицы DEPO под новым GUID на реального клиента из картотеки — по паспорту. Джоба bj2depo создаёт по нему документ; в НРД ничего не уходит. Полный сценарий приёма: получен → приняли → подали 16/1 → НРД принял → исполнено.</p>
|
||||
<form method="post" action="/admin/incoming/depo-sim" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center" onsubmit="return confirm('Записать копию перевода в трансферные таблицы DEPO на клиента с этим паспортом?')">
|
||||
<input type="hidden" name="id" value="{{.It.ID}}">
|
||||
<label style="margin:0;font-size:12.5px">серия <input type="text" name="ser" value="1110" style="width:70px"></label>
|
||||
<label style="margin:0;font-size:12.5px">номер <input type="text" name="num" value="754503" style="width:90px"></label>
|
||||
<label style="margin:0;font-size:12.5px">серия <input type="text" name="ser" value="" placeholder="0000" required style="width:70px"></label>
|
||||
<label style="margin:0;font-size:12.5px">номер <input type="text" name="num" value="" placeholder="000000" required style="width:90px"></label>
|
||||
<button type="submit" class="btn">🗂 Прогнать в DEPO с реальным клиентом</button>
|
||||
<span class="muted" style="font-size:12px">по умолчанию — Погонщикова В.О. (ВТНКЦ00000816 / BR859)</span>
|
||||
<span class="muted" style="font-size:12px">паспорт клиента из картотеки DEPO</span>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<style>
|
||||
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
{{define "content"}}
|
||||
<style>
|
||||
.mf-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px 14px}
|
||||
.mf-grid label{display:block;font-size:12px;color:var(--muted);margin-bottom:3px}
|
||||
.mf-grid input,.mf-grid select,.mf-grid textarea{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px;box-sizing:border-box}
|
||||
.mf-grid .wide{grid-column:1/-1}
|
||||
.mf-h{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:18px 0 8px}
|
||||
.mf-sec{display:grid;grid-template-columns:1.2fr .9fr 2fr .7fr auto;gap:8px;align-items:center;margin-bottom:6px}
|
||||
.mf-sec input{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px;box-sizing:border-box}
|
||||
.mf-sec .x{border:none;background:none;color:var(--err);font-size:18px;cursor:pointer}
|
||||
@media (max-width:720px){.mf-sec{grid-template-columns:1fr 1fr}.mf-sec .nm{grid-column:1/-1}}
|
||||
.mf-req::after{content:" *";color:var(--err)}
|
||||
.mf-note{font-size:12.5px;color:var(--muted)}
|
||||
.mf-block{list-style:none;margin:8px 0 0;padding:0}
|
||||
.mf-block li{padding:4px 0 4px 22px;position:relative;font-size:13.5px}
|
||||
.mf-block li::before{content:"✕";position:absolute;left:2px;color:var(--err);font-weight:700}
|
||||
.mf-chk{display:flex;gap:10px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
.mf-chk input{width:18px;height:18px;margin-top:2px;flex:none}
|
||||
.mf-chk .t{font-weight:600;font-size:13.5px}
|
||||
.mf-chk .h{font-size:12.5px;color:var(--muted);margin-top:2px}
|
||||
.mf-chk .who{font-size:12px;color:var(--ok);margin-top:2px}
|
||||
.mf-grp{font-size:12px;font-weight:700;color:var(--accent);margin:14px 0 0;text-transform:uppercase;letter-spacing:.04em}
|
||||
.mf-steps{list-style:none;margin:0;padding:0}
|
||||
.mf-steps li{display:flex;gap:12px;padding:9px 0;border-bottom:1px solid var(--border)}
|
||||
.mf-steps .d{width:24px;height:24px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;background:var(--card-2);color:var(--muted);border:1px solid var(--border)}
|
||||
.mf-steps .done .d{background:var(--ok-weak);color:var(--ok);border-color:var(--ok)}
|
||||
.mf-steps .wait .d{background:var(--warn-weak);color:var(--warn);border-color:var(--warn)}
|
||||
.mf-steps .fail .d{background:var(--err-weak);color:var(--err);border-color:var(--err)}
|
||||
.mf-steps .t{font-weight:600;font-size:13.5px}
|
||||
.mf-steps .s{font-size:12.5px;color:var(--muted)}
|
||||
.mf-kv{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:4px 14px;font-size:13.5px}
|
||||
.mf-kv .k{color:var(--muted)}
|
||||
.dc-ok{color:var(--ok);font-weight:700}.dc-bad{color:var(--err);font-weight:700}.dc-warn{color:var(--warn);font-weight:700}
|
||||
.mf-sign{display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-top:40px}
|
||||
.mf-sign div{border-top:1px solid #000;padding-top:4px;font-size:12px}
|
||||
@media print{
|
||||
header,nav,.topbar,.banner-mock,.no-print{display:none!important}
|
||||
body{background:#fff!important;color:#000!important}
|
||||
.card{box-shadow:none!important;border:1px solid #999!important;break-inside:avoid}
|
||||
main{padding:0!important;max-width:none!important}
|
||||
}
|
||||
</style>
|
||||
|
||||
{{if .Flash}}<div class="card no-print" style="border-color:var(--accent)">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{/* ======================= СПИСОК ======================= */}}
|
||||
{{if eq .Mode "list"}}
|
||||
<div class="section-head" style="align-items:center">
|
||||
<h1 style="margin:0">Ручной ввод M2M</h1>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a href="/admin/help/manual" class="btn btn-secondary">Инструкция</a>
|
||||
{{if can "operate"}}<a href="/admin/manual/new" class="btn btn-ok">+ Новое поручение</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="max-width:80ch">Исходящий перевод ценных бумаг через MOEX МОСТ по <b>бумажному поручению клиента</b>. Сотрудник вводит поручение так, как клиент заполнил бы заявку в личном кабинете, сверяет его с учётной и торговой системами, проставляет обязательные отметки и отправляет. Дальше перевод идёт тем же путём, что и заявка из личного кабинета: ответ контрагента, поручение 16, отчёт НРД об исполнении.</p>
|
||||
<div class="card">
|
||||
{{if .List}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>№</th><th>Поручение клиента</th><th>Клиент</th><th>Бумаги</th><th>Принимающий депозитарий</th><th>Состояние</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .List}}
|
||||
<tr>
|
||||
<td><code>{{.O.Number}}</code></td>
|
||||
<td class="num">№ {{.O.PaperNo}} от {{.O.PaperDate}}</td>
|
||||
<td>{{.O.FullName}}</td>
|
||||
<td class="num">{{len .O.Securities}}</td>
|
||||
<td>{{if .O.RecvName}}{{.O.RecvName}}{{else}}ИНН {{.O.RecvINN}}{{end}}</td>
|
||||
<td><span class="badge {{if eq .State "done"}}done{{else if or (eq .State "rejected") (eq .State "declined")}}rejected{{else if eq .State "sent"}}awaiting_decision{{else}}draft{{end}}">{{.StateRu}}</span></td>
|
||||
<td><a href="/admin/manual/{{.O.ID}}">открыть →</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else}}
|
||||
<p class="muted" style="margin:0">Поручений ещё нет. Нажмите «Новое поручение», когда клиент принесёт поручение на перевод.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
||||
{{if eq .Mode "edit"}}
|
||||
<p class="no-print" style="margin:0 0 12px"><a href="/admin/manual" class="btn">← К списку</a></p>
|
||||
<div class="card">
|
||||
<h2>Новое поручение на перевод M2M</h2>
|
||||
<p class="muted">Заполните поля по бумажному поручению клиента. После сохранения выполните сверку с учётной системой и проставьте обязательные отметки — только после этого поручение можно отправить.</p>
|
||||
{{template "manualForm" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ======================= КАРТОЧКА ======================= */}}
|
||||
{{if or (eq .Mode "card") (eq .Mode "print")}}
|
||||
{{$o := .O}}
|
||||
<div class="no-print" style="display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px">
|
||||
<a href="/admin/manual" class="btn">← К списку</a>
|
||||
<a href="/admin/manual/{{$o.ID}}/print" class="btn btn-secondary" target="_blank">Лист сверки для печати</a>
|
||||
{{if $o.DealID}}<a href="/admin/claims/{{$o.DealID}}" class="btn btn-secondary">Карточка перевода</a>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
|
||||
<h2 style="margin:0">{{if eq .Mode "print"}}Лист сверки поручения на перевод ценных бумаг (MOEX МОСТ){{else}}Поручение {{$o.Number}}{{end}}</h2>
|
||||
<span class="badge {{if eq .Progress.Outcome "done"}}done{{else if eq .Progress.Outcome "rejected"}}rejected{{else if eq $o.Status "sent"}}awaiting_decision{{else}}draft{{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}}</span>
|
||||
</div>
|
||||
<div class="mf-kv" style="margin-top:12px">
|
||||
<div class="k">Номер в системе</div><div>{{$o.Number}} · введено {{msk $o.CreatedAt}}{{if $o.CreatedBy}} · {{$o.CreatedBy}}{{end}}</div>
|
||||
<div class="k">Поручение клиента</div><div>№ {{$o.PaperNo}} от {{$o.PaperDate}} · {{$o.ReceivedVia}}</div>
|
||||
<div class="k">Клиент</div><div><b>{{$o.FullName}}</b>, дата рождения {{$o.BirthDate}}</div>
|
||||
<div class="k">Документ</div><div>код {{$o.DocType}} · {{$o.DocSeries}} № {{$o.DocNumber}}{{if $o.DocIssueDate}} · выдан {{$o.DocIssueDate}}{{end}}{{if $o.DocIssuer}} · {{$o.DocIssuer}}{{end}}</div>
|
||||
<div class="k">Счёт депо / договор</div><div>{{$o.Account}}{{if $o.Contract}} · договор {{$o.Contract}}{{end}}{{if $o.QuikCode}} · код в торговой системе {{$o.QuikCode}}{{end}}</div>
|
||||
<div class="k">Принимающий депозитарий</div><div>{{$o.RecvName}} · ИНН {{$o.RecvINN}}{{if $o.RecvCode}} · {{$o.RecvCode}}{{end}}{{if $o.RecvAccount}} · счёт клиента {{$o.RecvAccount}}{{end}}</div>
|
||||
<div class="k">Место расчётов</div><div>{{if eq $o.SettlePlace "spb"}}СПБ Банк{{else}}НРД{{end}}{{if $o.CostInfo}} · со сведениями о расходах{{end}}</div>
|
||||
<div class="k">Бумаги</div><div>{{range $o.Securities}}<div>{{.ISIN}}{{if .Code}} ({{.Code}}){{end}}{{if .Name}} — {{.Name}}{{end}} × <b>{{.Qty}}</b></div>{{end}}</div>
|
||||
{{if $o.Comment}}<div class="k">Комментарий</div><div>{{$o.Comment}}</div>{{end}}
|
||||
{{if $o.GUID}}<div class="k">Идентификатор перевода</div><div><code>{{$o.GUID}}</code>, отправлено {{msk $o.SentAt}} · {{$o.SentBy}}</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if and (eq $o.Status "draft") (eq .Mode "card")}}
|
||||
{{if $o.SendErr}}<div class="card" style="border-color:var(--err)"><b style="color:var(--err)">Последняя попытка отправки не удалась:</b> {{$o.SendErr}}</div>{{end}}
|
||||
<div class="card no-print">
|
||||
<h2>Реквизиты</h2>
|
||||
{{if .Problems}}<p class="muted" style="margin:0">Нужно исправить:</p><ul class="mf-block">{{range .Problems}}<li>{{.}}</li>{{end}}</ul>{{end}}
|
||||
<details {{if .Problems}}open{{end}} style="margin-top:10px"><summary style="cursor:pointer;color:var(--accent)">Изменить данные поручения</summary>
|
||||
<div style="margin-top:12px">{{template "manualForm" .}}</div>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---- Сверка с учётной системой ---- */}}
|
||||
{{if or $o.Depo (and (eq $o.Status "draft") .DepoOn)}}
|
||||
<div class="card" id="depo">
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<h2 style="margin:0">Сверка с учётной системой (DEPO)</h2>
|
||||
{{if and (eq $o.Status "draft") .DepoOn (eq $.Mode "card")}}
|
||||
<form method="post" action="/admin/manual/save" class="no-print" style="margin:0" id="depoRerun">
|
||||
<input type="hidden" name="id" value="{{$o.ID}}"><input type="hidden" name="do" value="depo">
|
||||
<button type="button" class="btn" onclick="mfRerun()">{{if $o.Depo}}Сверить заново{{else}}Сверить с DEPO{{end}}</button>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
{{with $o.Depo}}
|
||||
<p style="margin:10px 0 6px">{{msk .At}}{{if .By}} · {{.By}}{{end}} ·
|
||||
{{if not .Ran}}<span class="dc-bad">не выполнена: {{.Err}}</span>{{else if eq .Recommend "confirm"}}<span class="dc-ok">{{.Summary}}</span>{{else}}<span class="dc-bad">{{.Summary}}</span>{{end}}</p>
|
||||
{{if $.DepoStale}}<p class="dc-warn">Данные поручения изменены после сверки — выполните сверку заново.</p>{{end}}
|
||||
{{if .Ran}}<p class="muted" style="margin:0 0 8px">Инвестор в DEPO: {{if .Investor}}{{.Investor}}{{else}}не найден{{end}} · счёт: {{if .Account}}{{.Account}}{{else}}—{{end}}</p>{{end}}
|
||||
{{range .Secs}}
|
||||
<div class="mf-grp">{{.Security}} — {{if eq .Recommend "confirm"}}<span class="dc-ok">можно списать</span>{{else}}<span class="dc-bad">нельзя{{if .RejectCode}} ({{.RejectCode}}){{end}}</span>{{end}}</div>
|
||||
<div class="tw"><table style="margin-top:4px"><tbody>
|
||||
{{range .Checks}}<tr><td style="width:24px">{{if not .OK}}<span class="dc-bad">✕</span>{{else if .Warn}}<span class="dc-warn">!</span>{{else}}<span class="dc-ok">✓</span>{{end}}</td><td style="width:38%">{{.Name}}</td><td class="muted">{{.Detail}}</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted" style="margin:10px 0 0">Сверка ещё не выполнялась. Она проверяет клиента, счёт, остаток, блокировки, ограничения по бумагам — те же проверки, что и для заявок из личного кабинета.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if and (eq $o.Status "draft") (not .DepoOn)}}
|
||||
<div class="card"><h2>Сверка с учётной системой</h2><p class="muted" style="margin:0">Учётная система депозитария не подключена к bj — сверку выполните вручную и подтвердите отметками ниже.</p></div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---- Обязательные отметки ---- */}}
|
||||
{{if or (eq $o.Status "draft") $o.Checks}}
|
||||
<div class="card" id="checks">
|
||||
<h2>Обязательные отметки перед отправкой</h2>
|
||||
{{$editable := and (eq $o.Status "draft") (eq .Mode "card") (can "operate")}}
|
||||
<form method="post" action="/admin/manual/{{$o.ID}}/checks" id="chkForm">
|
||||
{{$grp := ""}}
|
||||
{{range .PreChecks}}
|
||||
{{if ne .Group $grp}}<div class="mf-grp">{{.Group}}</div>{{$grp = .Group}}{{end}}
|
||||
{{$m := index $o.Checks .Key}}
|
||||
<label class="mf-chk"><input type="checkbox" name="check_{{.Key}}" value="1" {{if $m.Done}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
|
||||
<span><div class="t">{{.Title}}</div><div class="h">{{.Hint}}</div>{{if $m.Done}}<div class="who">✓ {{$m.By}} · {{msk $m.At}}</div>{{end}}</span></label>
|
||||
{{end}}
|
||||
<div class="mf-grp">Торговая система (QUIK)</div>
|
||||
<label class="mf-chk"><input type="radio" name="quik_mode" value="auto" {{if eq $o.QuikMode "auto"}}checked{{end}} {{if or (not $editable) (not .QuikAuto)}}disabled{{end}} onchange="this.form.submit()">
|
||||
<span><div class="t">Заблокировать бумаги автоматически при отправке</div><div class="h">{{if .QuikAuto}}bj зарегистрирует в модуле неторговых поручений вывод бумаг (лимит уменьшится), по исполнению подпишет списание, по отказу снимет блокировку.{{else}}Недоступно: автоматический цикл в торговой системе не включён в настройках.{{end}}</div></span></label>
|
||||
<label class="mf-chk"><input type="radio" name="quik_mode" value="manual" {{if eq $o.QuikMode "manual"}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
|
||||
<span><div class="t">Бумаги заблокированы вручную (лимит уменьшен)</div><div class="h">Позиция клиента проверена, поручение на вывод зарегистрировано сотрудником. После ответа отметьте списание или снятие блокировки.</div>
|
||||
{{if eq $o.QuikMode "manual"}}<div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><span class="mf-note">№ поручения в торговой системе:</span><input name="quik_instr" value="{{$o.QuikInstr}}" {{if not $editable}}disabled{{end}} style="max-width:200px;padding:5px 8px;border-radius:7px;border:1px solid var(--border);background:var(--card);color:var(--text)">{{if $editable}}<button type="submit" class="btn btn-secondary" style="padding:5px 10px">Сохранить</button>{{end}}</div>{{end}}
|
||||
</span></label>
|
||||
<label class="mf-chk"><input type="radio" name="quik_mode" value="none" {{if eq $o.QuikMode "none"}}checked{{end}} {{if not $editable}}disabled{{end}} onchange="this.form.submit()">
|
||||
<span><div class="t">Блокировка в торговой системе не требуется</div><div class="h">Бумаги не учитываются в торговой системе (не на торговом разделе, клиент не пользуется брокерским обслуживанием).</div></span></label>
|
||||
{{if and $editable (ne $o.QuikMode "manual")}}<input type="hidden" name="quik_instr" value="{{$o.QuikInstr}}">{{end}}
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---- Отправка ---- */}}
|
||||
{{if and (eq $o.Status "draft") (eq .Mode "card")}}
|
||||
<div class="card no-print" id="send">
|
||||
<h2>Отправка в НРД</h2>
|
||||
{{if .Blockers}}
|
||||
<p class="muted" style="margin:0">Отправить пока нельзя:</p>
|
||||
<ul class="mf-block">{{range .Blockers}}<li>{{.}}</li>{{end}}</ul>
|
||||
{{else}}
|
||||
<p style="margin:0"><span class="dc-ok">Все проверки и отметки пройдены.</span> Поручение уйдёт в НРД тем же путём, что и заявка из личного кабинета{{if eq $o.QuikMode "auto"}}, бумаги будут заблокированы в торговой системе перед отправкой{{end}}.</p>
|
||||
{{end}}
|
||||
{{if can "operate"}}
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:14px;align-items:flex-start">
|
||||
<form method="post" action="/admin/manual/{{$o.ID}}/send" onsubmit="return confirm('Отправить поручение {{$o.Number}} ({{$o.FullName}}) в НРД? Отменить отправку будет нельзя.')">
|
||||
<button type="submit" class="btn btn-ok" {{if .Blockers}}disabled style="opacity:.5;cursor:not-allowed"{{end}}>Отправить в НРД</button>
|
||||
</form>
|
||||
<details><summary class="btn btn-secondary" style="list-style:none;cursor:pointer">Отклонить поручение…</summary>
|
||||
<form method="post" action="/admin/manual/{{$o.ID}}/decline" style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap" onsubmit="return confirm('Отклонить поручение без отправки в НРД?')">
|
||||
<input name="reason" required placeholder="причина отказа клиенту" style="min-width:320px;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text)">
|
||||
<button type="submit" class="btn">Отклонить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ---- Ход исполнения ---- */}}
|
||||
{{if ne $o.Status "draft"}}
|
||||
<div class="card" id="progress">
|
||||
<h2>Ход исполнения</h2>
|
||||
<ul class="mf-steps">
|
||||
{{range $i, $s := .Progress.Steps}}
|
||||
<li class="{{$s.State}}"><span class="d">{{if eq $s.State "done"}}✓{{else if eq $s.State "fail"}}✕{{else}}{{add $i 1}}{{end}}</span>
|
||||
<div><div class="t">{{$s.Title}}</div><div class="s">{{if not $s.At.IsZero}}{{msk $s.At}}{{end}}{{if and (not $s.At.IsZero) $s.Detail}} · {{end}}{{$s.Detail}}</div></div></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{with .Progress.Depo}}
|
||||
<div class="no-print" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||
{{if and (not .Submitted) (can "operate")}}
|
||||
<form method="post" action="/admin/outgoing/{{.GUID}}/submit-order" style="margin:0" onsubmit="return confirm('Подать поручение 16 (списание) в НРД по полученному черновику?')">
|
||||
<input type="hidden" name="back" value="/admin/manual/{{$o.ID}}">
|
||||
<button type="submit" class="btn btn-ok">Подать поручение 16 по черновику</button>
|
||||
</form>
|
||||
{{if $.Auto}}<span class="mf-note" style="align-self:center">Автоматический режим включён — bj подаст поручение сам в течение минуты.</span>{{end}}
|
||||
{{end}}
|
||||
<a href="/admin/outgoing/{{.GUID}}/draft.zip" class="btn btn-secondary">Черновик 16 (ZIP)</a>
|
||||
{{if .Submitted}}<a href="/admin/outgoing/{{.GUID}}/order.xml" class="btn btn-secondary">Поданное поручение 16</a>{{end}}
|
||||
{{if .Done}}<a href="/admin/outgoing/{{.GUID}}/ms010.zip" class="btn btn-ok">Документы НРД (ZIP + PDF)</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Progress.Quik}}
|
||||
<div class="mf-grp">Торговая система</div>
|
||||
<div class="tw"><table style="margin-top:4px"><tbody>
|
||||
{{range .Progress.Quik}}<tr><td>{{.ISIN}} × {{.Qty}}</td><td>{{.StateRu}}</td><td class="muted">{{if .InstrNo}}поручение № {{.InstrNo}}{{end}}{{if .Err}} · {{.Err}}{{end}}</td></tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{else if eq $o.QuikMode "manual"}}<p class="muted" style="margin:10px 0 0">Торговая система: бумаги заблокированы вручную, поручение № {{$o.QuikInstr}}.</p>
|
||||
{{else if eq $o.QuikMode "none"}}<p class="muted" style="margin:10px 0 0">Торговая система: блокировка не требовалась.</p>{{end}}
|
||||
</div>
|
||||
|
||||
{{/* ---- Отметки после ответа ---- */}}
|
||||
{{if .Progress.Outcome}}
|
||||
<div class="card" id="post">
|
||||
<h2>{{if eq .Progress.Outcome "done"}}После исполнения{{else}}После отказа{{end}}</h2>
|
||||
<form method="post" action="/admin/manual/{{$o.ID}}/post">
|
||||
{{range .PostChecks}}{{if and (eq .When $.Progress.Outcome) (or (not .Quik) (eq $o.QuikMode "manual"))}}
|
||||
{{$m := index $o.Post .Key}}
|
||||
<input type="hidden" name="shown_{{.Key}}" value="1">
|
||||
<label class="mf-chk"><input type="checkbox" name="post_{{.Key}}" value="1" {{if $m.Done}}checked{{end}} {{if or (ne $.Mode "card") (not (can "operate"))}}disabled{{end}} onchange="this.form.submit()">
|
||||
<span><div class="t">{{.Title}}</div>{{if $m.Done}}<div class="who">✓ {{$m.By}} · {{msk $m.At}}</div>{{end}}</span></label>
|
||||
{{end}}{{end}}
|
||||
</form>
|
||||
{{if and (eq .Progress.Outcome "rejected") (eq $o.QuikMode "auto")}}<p class="mf-note">Блокировку в торговой системе bj снимает автоматически — состояние видно выше.</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if eq .Mode "print"}}
|
||||
<div class="mf-sign">
|
||||
<div>Сотрудник депозитария (ФИО, подпись)</div>
|
||||
<div>Контролёр (ФИО, подпись)</div>
|
||||
</div>
|
||||
<p class="mf-note" style="margin-top:20px">Сформировано {{.Now}}.</p>
|
||||
<script>window.addEventListener('load',()=>setTimeout(()=>window.print(),300))</script>
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('details')&&f.closest('details').open){const b=document.createElement('input');b.type='hidden';b.name='do';b.value='depo';f.appendChild(b);f.submit()}else{document.getElementById('depoRerun').submit()}}
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* ======================= ФОРМА (новое / черновик) ======================= */}}
|
||||
{{define "manualForm"}}
|
||||
<form method="post" action="/admin/manual/save" id="mf">
|
||||
<input type="hidden" name="id" value="{{.O.ID}}">
|
||||
<div class="mf-h" style="margin-top:0">1. Основание</div>
|
||||
<div class="mf-grid">
|
||||
<div><label class="mf-req">Номер поручения клиента</label><input name="paper_no" value="{{.O.PaperNo}}" required></div>
|
||||
<div><label class="mf-req">Дата поручения</label><input type="date" name="paper_date" value="{{.O.PaperDate}}" required></div>
|
||||
<div><label>Как получено</label>
|
||||
<select name="received_via">
|
||||
{{$v := .O.ReceivedVia}}
|
||||
{{range $opt := (list "лично в офисе" "через представителя по доверенности" "почтой" "курьером" "через систему электронного документооборота")}}<option {{if eq $v $opt}}selected{{end}}>{{$opt}}</option>{{end}}
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
<div class="mf-h">2. Клиент (как в поручении и анкете депонента)</div>
|
||||
<div class="mf-grid">
|
||||
<div><label class="mf-req">Вид документа</label>
|
||||
<select name="doc_type">{{$dt := .O.DocType}}{{range .DocTypes}}<option value="{{.Code}}" {{if eq $dt .Code}}selected{{end}}>{{.Code}} — {{.Title}}</option>{{end}}</select></div>
|
||||
<div><label>Серия</label><input name="doc_series" value="{{.O.DocSeries}}" placeholder="4 цифры для паспорта РФ"></div>
|
||||
<div><label class="mf-req">Номер</label><input name="doc_number" value="{{.O.DocNumber}}"></div>
|
||||
<div style="display:flex;align-items:flex-end">{{if .DepoOn}}<button type="submit" name="do" value="lookup" class="btn btn-secondary" style="width:100%" title="Найти клиента в учётной системе по документу и заполнить пустые поля">Найти в DEPO по документу</button>{{end}}</div>
|
||||
<div><label>Дата выдачи</label><input type="date" name="doc_issue_date" value="{{.O.DocIssueDate}}"></div>
|
||||
<div class="wide"><label>Кем выдан</label><input name="doc_issuer" value="{{.O.DocIssuer}}"></div>
|
||||
<div><label class="mf-req">Фамилия</label><input name="last_name" value="{{.O.LastName}}"></div>
|
||||
<div><label class="mf-req">Имя</label><input name="first_name" value="{{.O.FirstName}}"></div>
|
||||
<div><label>Отчество</label><input name="middle_name" value="{{.O.MiddleName}}"></div>
|
||||
<div><label class="mf-req">Дата рождения</label><input type="date" name="birth_date" value="{{.O.BirthDate}}"></div>
|
||||
<div><label>Договор (брокерский / депозитарный)</label><input name="contract" value="{{.O.Contract}}"></div>
|
||||
<div><label class="mf-req">Счёт депо клиента</label><input name="account" value="{{.O.Account}}" placeholder="торговый счёт депо"></div>
|
||||
<div><label>Код клиента в торговой системе</label><input name="quik_code" value="{{.O.QuikCode}}"></div>
|
||||
<div><label>Телефон для связи</label><input name="phone" value="{{.O.Phone}}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mf-h">3. Ценные бумаги</div>
|
||||
<div class="mf-sec mf-note" style="margin-bottom:2px"><span class="mf-req">ISIN</span><span>Код НРД</span><span class="nm">Наименование</span><span class="mf-req">Количество</span><span></span></div>
|
||||
<div id="secs">
|
||||
{{range .O.Securities}}
|
||||
<div class="mf-sec"><input name="sec_isin" value="{{.ISIN}}" placeholder="RU…" maxlength="12"><input name="sec_code" value="{{.Code}}" placeholder="необязательно"><input class="nm" name="sec_name" value="{{.Name}}" placeholder="заполнится из DEPO"><input name="sec_qty" value="{{.Qty}}" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" onclick="mfAdd()" style="margin-top:4px">+ Бумага</button>
|
||||
<p class="mf-note">Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.</p>
|
||||
|
||||
<div class="mf-h">4. Принимающая сторона</div>
|
||||
<div class="mf-grid">
|
||||
<div class="wide"><label>Депозитарий из справочника участников M2M</label>
|
||||
<select id="hb" onchange="mfPickHB(this)">
|
||||
<option value="">— выбрать —</option>
|
||||
{{$inn := .O.RecvINN}}{{range .Handbook}}<option value="{{.INN}}" data-name="{{.Name}}" {{if eq $inn .INN}}selected{{end}}>{{.Name}} · ИНН {{.INN}} · {{.Code}}</option>{{end}}
|
||||
</select>
|
||||
{{if not .Handbook}}<span class="mf-note">Справочник участников ещё не получен от НРД — введите ИНН вручную.</span>{{end}}</div>
|
||||
<div><label class="mf-req">ИНН принимающего депозитария</label><input name="recv_inn" id="recv_inn" value="{{.O.RecvINN}}" maxlength="10" inputmode="numeric"></div>
|
||||
<div><label>Наименование</label><input name="recv_name" id="recv_name" value="{{.O.RecvName}}"></div>
|
||||
<div><label>Счёт клиента у принимающего</label><input name="recv_account" value="{{.O.RecvAccount}}" placeholder="для сведения"></div>
|
||||
<div><label>Место расчётов</label>
|
||||
<select name="settle_place"><option value="nrd" {{if ne .O.SettlePlace "spb"}}selected{{end}}>НРД</option><option value="spb" {{if eq .O.SettlePlace "spb"}}selected{{end}}>СПБ Банк</option></select></div>
|
||||
<div class="wide"><label style="display:flex;gap:8px;align-items:center;color:var(--text);font-size:13.5px"><input type="checkbox" name="cost_info" value="1" style="width:auto" {{if .O.CostInfo}}checked{{end}}> Передать сведения о расходах на приобретение (если клиент просил в поручении)</label></div>
|
||||
</div>
|
||||
<p class="mf-note">Бумаги списываются с нашего места хранения в НРД: {{if .NSD.DeponentCode}}депонент <code>{{.NSD.DeponentCode}}</code>, счёт <code>{{.NSD.AccountID}}</code>, раздел <code>{{.NSD.SectionID}}</code>{{else}}реквизиты не заданы в настройках НРД{{end}}.</p>
|
||||
|
||||
<div class="mf-h">5. Комментарий</div>
|
||||
<div class="mf-grid"><div class="wide"><textarea name="comment" rows="2" placeholder="особые условия, контакты представителя и т.п.">{{.O.Comment}}</textarea></div></div>
|
||||
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">
|
||||
<button type="submit" name="do" value="save" class="btn btn-ok">Сохранить</button>
|
||||
{{if .DepoOn}}<button type="submit" name="do" value="depo" class="btn">Сохранить и сверить с DEPO</button>{{end}}
|
||||
<a href="/admin/manual" class="btn btn-secondary">К списку</a>
|
||||
</div>
|
||||
</form>
|
||||
<template id="secRow"><div class="mf-sec"><input name="sec_isin" placeholder="RU…" maxlength="12"><input name="sec_code" placeholder="необязательно"><input class="nm" name="sec_name" placeholder="заполнится из DEPO"><input name="sec_qty" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div></template>
|
||||
<script>
|
||||
function mfAdd(){document.getElementById('secs').appendChild(document.getElementById('secRow').content.cloneNode(true))}
|
||||
function mfDel(b){const rows=document.querySelectorAll('#secs .mf-sec');if(rows.length>1){b.parentNode.remove()}else{b.parentNode.querySelectorAll('input').forEach(i=>i.value='')}}
|
||||
function mfPickHB(s){const o=s.options[s.selectedIndex];if(!o.value)return;document.getElementById('recv_inn').value=o.value;document.getElementById('recv_name').value=o.dataset.name||''}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -13,7 +13,7 @@
|
||||
<button data-sec="crypto"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
|
||||
<button data-sec="nsd"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M4 21V10M20 21V10M3 10l9-6 9 6M8 21v-7M12 21v-7M16 21v-7"/></svg></span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
|
||||
<button data-sec="lk"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16c.4-1.4 1.6-2.2 3-2.2s2.6.8 3 2.2"/><path d="M15 10h4M15 13h3"/></svg></span>Личный кабинет <span class="muted" style="font-size:10px;font-weight:500">ЕСИА Финанс</span><span class="ind {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span></button>
|
||||
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Тесты</button>
|
||||
<button data-sec="tests"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
|
||||
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
|
||||
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
|
||||
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
|
||||
@@ -195,7 +195,7 @@
|
||||
{{template "verline" (.Versions.Get "quik-mnp")}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Quik.Verified}}ok{{else if .Settings.Quik.Server}}warn{{else}}err{{end}}"></span>Подключение к БД МНП QUIK (MSSQL)</h2>
|
||||
<p class="muted">При выводе бумаг клиентом bj блокирует их количество в QUIK через API Модуля неторговых поручений (хранимые процедуры). БД <strong>боевая</strong> — тест только на чтение (версия + наличие M2M-объектов), без записи. Подключение под логином с ролью <code>InstrEu</code>.<br>
|
||||
<p class="muted">При выводе бумаг клиентом bj блокирует их количество в QUIK через API Модуля неторговых поручений (хранимые процедуры). БД <strong>боевая</strong> — проверка только на чтение (версия + наличие M2M-объектов), без записи. Подключение под логином с ролью <code>InstrEu</code>.<br>
|
||||
<a href="/admin/help/quik"><strong>Инструкция для администратора QUIK</strong></a> — что настроить в QuikAdmin для M2M · <a href="/admin/docs/">документы ARQA</a>.</p>
|
||||
<form method="post" action="/admin/setup/quik" style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
||||
<label>Сервер (host или host\instance)
|
||||
@@ -228,7 +228,7 @@
|
||||
|
||||
<div class="card" id="sec-quik-cycle">
|
||||
<h2><span class="dot {{if .Settings.Quik.Emulate}}warn{{else if not .Settings.Quik.WritesEnabled}}warn{{else if or .Settings.Quik.AutoOut .Settings.Quik.AutoIn}}ok{{else}}warn{{end}}"></span>Автоматический цикл M2M в МНП{{if .Settings.Quik.Emulate}} <span class="hero-status warn">{{if .DevMode}}имитация{{else}}остановлен{{end}}</span>{{end}}</h2>
|
||||
<p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Проверено на боевом МНП 09.09.2026. Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p>
|
||||
<p class="muted">Что делает bj в Модуле неторговых поручений по ходу перевода: <strong>исходящий</strong> — при отправке в НРД регистрирует «Вывод ЦБ (M2M)» (QUIK блокирует бумаги), по подтверждению подписывает (списание), по отказу снимает с причиной; <strong>входящий</strong> — после приёма нашего поручения НРД регистрирует «Ввод ЦБ (M2M)» (зачисление сразу). Каждый шаг виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>.</p>
|
||||
<form method="post" action="/admin/setup/quik/cycle" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
|
||||
<input type="hidden" name="section" value="sec-quik-cycle">
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600">
|
||||
@@ -262,9 +262,9 @@
|
||||
|
||||
{{if .Settings.Quik.LastReport}}
|
||||
<div class="card">
|
||||
<h2>Полный ответ сервера (последний тест)</h2>
|
||||
<h2>Полный ответ сервера (последняя проверка)</h2>
|
||||
<p class="muted">Можно выделить и скопировать целиком — для отправки коллегам/аутсорсеру QUIK.
|
||||
{{if not .Settings.Quik.LastAt.IsZero}}Тест от {{.Settings.Quik.LastAt.Format "02.01.2006 15:04:05"}} UTC.{{end}}</p>
|
||||
{{if not .Settings.Quik.LastAt.IsZero}}Проверка от {{.Settings.Quik.LastAt.Format "02.01.2006 15:04:05"}} UTC.{{end}}</p>
|
||||
<div style="display:flex;gap:8px;margin:8px 0">
|
||||
<button type="button" class="btn" onclick="var t=document.getElementById('quik-report').textContent;navigator.clipboard&&navigator.clipboard.writeText(t);this.textContent='Скопировано ✓';setTimeout(()=>this.textContent='Скопировать отчёт',1500)">Скопировать отчёт</button>
|
||||
</div>
|
||||
@@ -397,10 +397,10 @@
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span>СКЗИ «Валидата Клиент L»</h2>
|
||||
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}}</code> · провайдер <code>{{.Settings.Crypto.Provider}}</code>. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
|
||||
<p class="muted">Активный профиль: <code>{{if .Settings.Crypto.Profile}}{{.Settings.Crypto.Profile}}{{else}}—{{end}}</code> · провайдер {{if and (not .DevMode) (or (eq .Settings.Crypto.Provider "stub") (eq .Settings.Crypto.Provider ""))}}<code>не настроен</code>{{else}}<code>{{.Settings.Crypto.Provider}}</code>{{end}}. Подробно — <a href="/admin/help/crypto">справка</a>.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||
<form method="post" action="/admin/setup/crypto/check" style="margin:0"><button type="submit" class="btn btn-ok">✓ Проверить СКЗИ</button></form>
|
||||
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Тестовая подпись</button></form>
|
||||
<form method="post" action="/admin/setup/crypto/test-sign" style="margin:0"><button type="submit" class="btn">✎ Пробная подпись</button></form>
|
||||
<form method="post" action="/admin/setup/restart-crypto" style="margin:0" onsubmit="return confirm('Перезапустить crypto-service? Поднимется через ~5 сек.');"><button type="submit" class="btn btn-warn">↻ crypto-service</button></form>
|
||||
<form method="post" action="/admin/setup/restart-server" style="margin:0" onsubmit="return confirm('Перезапустить bj-server? Через 5-10 сек страница вернётся.');"><button type="submit" class="btn btn-secondary">↻ bj-server</button></form>
|
||||
</div>
|
||||
@@ -409,7 +409,7 @@
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px;max-width:640px">
|
||||
<label>Провайдер</label>
|
||||
<select name="provider">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
{{if .DevMode}} <option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>{{end}}
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
|
||||
</select>
|
||||
<label>Путь к модулю PKCS#11</label>
|
||||
@@ -480,21 +480,22 @@
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if .Settings.M2MAuto.Enabled}}checked{{end}}> Автоматический режим включён</label>
|
||||
<button type="submit" class="btn btn-ok">Сохранить</button>
|
||||
</form>
|
||||
{{if not .Settings.M2MAuto.ChangedAt.IsZero}}<p class="muted" style="font-size:12px;margin-top:8px">{{if .Settings.M2MAuto.Enabled}}Включён{{else}}Выключен{{end}} {{msk .Settings.M2MAuto.ChangedAt}}{{with .Settings.M2MAuto.ChangedBy}} · {{.}}{{end}}.{{if .Settings.Quik.Emulate}} Шаги QUIK МНП — в режиме имитации.{{end}}</p>{{end}}
|
||||
{{if not .Settings.M2MAuto.ChangedAt.IsZero}}<p class="muted" style="font-size:12px;margin-top:8px">{{if .Settings.M2MAuto.Enabled}}Включён{{else}}Выключен{{end}} {{msk .Settings.M2MAuto.ChangedAt}}{{with .Settings.M2MAuto.ChangedBy}} · {{.}}{{end}}.{{if .Settings.Quik.Emulate}}{{if .DevMode}} Шаги QUIK МНП — в режиме имитации.{{else}} Записи в QUIK МНП сейчас не выполняются.{{end}}{{end}}</p>{{end}}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span>Интеграционный шлюз (ИШ)</h2>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}<code>mock-режим</code> — без реального ИШ.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}{{if .DevMode}}<code>mock-режим</code> — без реального ИШ.{{else}}ИШ не настроен — обмен с НРД не выполняется.{{end}}{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}} Стенды и установка — <a href="/admin/help/systems">справка</a>.</p>
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||
<label>Профиль / контур</label>
|
||||
<select name="profile" id="nsd-profile">
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — TEST3, ГОСТ</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — TEST3, RSA</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — ПРОМ, ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — ПРОМ, RSA</option>
|
||||
{{/* mock и GUEST — для разработчиков; у клиента — только контуры НРД */}}
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>{{if .DevMode}}— mock (демо) —{{else}}— не выбран —{{end}}</option>
|
||||
{{if or .DevMode (eq .Settings.NSD.Profile "guest-gost") (eq .Settings.NSD.Profile "guest-rsa")}} <option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost — GUEST, ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa — GUEST, RSA</option>{{end}}
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost — тестовый контур НРД (TEST3), ГОСТ</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa — тестовый контур НРД (TEST3), RSA</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost — боевой контур НРД, ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa — боевой контур НРД, RSA</option>
|
||||
</select>
|
||||
<label>URL ONYX (WSL) НРД</label>
|
||||
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="автозаполнится по профилю">
|
||||
@@ -551,7 +552,7 @@
|
||||
<input type="text" name="lk_public_url" value="{{.Settings.LK.PublicURL}}" placeholder="https://fontvielle.ef.winr.pro" style="flex:1">
|
||||
{{if .Settings.LK.PublicURL}}<a class="btn btn-secondary" href="{{.Settings.LK.PublicURL}}" target="_blank" rel="noopener">Открыть →</a>{{end}}
|
||||
</div>
|
||||
<span class="muted" style="font-size:12px">Показывается ссылкой на дашборде для первичного теста.</span>
|
||||
<span class="muted" style="font-size:12px">{{if .DevMode}}Показывается ссылкой на дашборде для первичного теста.{{else}}Адрес, по которому клиенты входят в личный кабинет.{{end}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Callback URL <span class="muted" style="font-weight:400">(куда bj шлёт статус заявки)</span></label>
|
||||
@@ -661,15 +662,15 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Забрать заявки из ЛК</h2>
|
||||
<p class="muted">Коннектор bj вычитывает заявки <code>depo_m2m</code> активного окружения в статусах «В очереди» и «В работе», которые ещё не обрабатывал, для каждой собирает M2M-пакет, отправляет в НРД и переводит заявку в статус <strong>«В работе»</strong>. По ответу НРД статус в ЛК замыкается автоматически (Исполнена / Отклонена). В режиме имитации НРД перед отправкой идёт сверка с DEPO: отказ сразу уходит в ЛК с кодом и причиной, одобренная заявка блокируется в QUIK и через 30 с получает имитированный ответ контрагента. Штатно это делает фоновый поллер раз в минуту — кнопка для ручного запуска.</p>
|
||||
<p class="muted">Коннектор bj вычитывает заявки <code>depo_m2m</code> активного окружения в статусах «В очереди» и «В работе», которые ещё не обрабатывал, для каждой собирает M2M-пакет, отправляет в НРД и переводит заявку в статус <strong>«В работе»</strong>. По ответу НРД статус в ЛК замыкается автоматически (Исполнена / Отклонена).{{if .DevMode}} В режиме имитации НРД перед отправкой идёт сверка с DEPO: отказ сразу уходит в ЛК с кодом и причиной, одобренная заявка блокируется в QUIK и через 30 с получает имитированный ответ контрагента.{{end}} Штатно это делает фоновый поллер раз в минуту — кнопка для ручного запуска.</p>
|
||||
<form method="post" action="/admin/setup/lk-pull" style="margin-top:12px"><button type="submit" class="btn">Забрать заявки сейчас</button></form>
|
||||
<p class="muted" style="font-size:12px;margin-top:10px">Итог (сколько получено / отправлено, ошибки) появится вверху страницы.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{/* ============ ТЕСТЫ ============ */}}
|
||||
{{/* ============ ДИАГНОСТИКА (id sec-tests сохранён для ссылок) ============ */}}
|
||||
<section class="settings-section" id="sec-tests">
|
||||
<h1>Тесты</h1>
|
||||
<h1>Диагностика</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Здоровье сервиса</h2>
|
||||
@@ -765,7 +766,7 @@
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
<button type="submit" formaction="/admin/setup/alerts-smtp-test" class="btn btn-secondary">Отправить тестовое письмо</button>
|
||||
<button type="submit" formaction="/admin/setup/alerts-smtp-test" class="btn btn-secondary">Отправить проверочное письмо</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .Settings.Alerts.SMTP.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.SMTP.LastMsg}}</p>{{end}}
|
||||
@@ -784,7 +785,7 @@
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
<button type="submit" formaction="/admin/setup/alerts-zabbix-test" class="btn btn-secondary">Отправить тестовое значение</button>
|
||||
<button type="submit" formaction="/admin/setup/alerts-zabbix-test" class="btn btn-secondary">Отправить проверочное значение</button>
|
||||
</div>
|
||||
</form>
|
||||
{{if .Settings.Alerts.Zabbix.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.Zabbix.LastMsg}}</p>{{end}}
|
||||
@@ -935,7 +936,7 @@
|
||||
<h1>Оформление отчётов</h1>
|
||||
<div class="card">
|
||||
<h2>Логотип организации в шапке отчётов</h2>
|
||||
<p class="muted">Логотип показывается <b>справа</b> в шапке всех отчётов для регулятора (слева — логотип MOEX МОСТ / НРД) и на титульной сводке пакета тестов. Встраивается прямо в HTML — отчёт остаётся автономным. Если не загружать — используется стандартный логотип «Фонтвьель».</p>
|
||||
<p class="muted">Логотип показывается <b>справа</b> в шапке всех отчётов для регулятора (слева — логотип MOEX МОСТ / НРД){{if .DevMode}} и на титульной сводке пакета тестов{{end}}. Встраивается прямо в HTML — отчёт остаётся автономным. Если не загружать — используется стандартный логотип «Фонтвьель».</p>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;margin:14px 0;padding:14px;background:var(--card-2);border-radius:12px">
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<p class="muted" style="margin-top: 16px">Проверка выполнена в {{.CheckedAt.Format "15:04:05 02.01.2006"}}.</p>
|
||||
</div>
|
||||
|
||||
{{if .DevMode}}{{/* дорожная карта разработки — только в режиме разработки */}}
|
||||
<div class="card">
|
||||
<h2>Что подключается на следующих этапах</h2>
|
||||
<table>
|
||||
@@ -28,3 +29,4 @@
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
|
||||
<label>DSN (строка подключения) <span class="tooltip" title="Формат: postgres://пользователь:пароль@хост:порт/база?sslmode=disable. Например: postgres://bj:secret@db.example.com:5432/bj?sslmode=require">?</span></label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:6px">
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется тестовое подключение (Ping). Если БД недоступна — будет ошибка.</p>
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется проверка подключения (Ping). Если БД недоступна — будет ошибка.</p>
|
||||
<button type="submit" class="btn" style="margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -91,9 +91,9 @@
|
||||
<summary style="cursor:pointer;color:var(--accent)">Параметры провайдера</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Провайдер <span class="tooltip" title="validata — АПК «Валидата Клиент L»; stub — без криптографии (демо-режим без подписи)">?</span></label>
|
||||
<label>Провайдер <span class="tooltip" title="validata — АПК «Валидата Клиент L»{{if .DevMode}}; stub — без криптографии (демо-режим без подписи){{end}}">?</span></label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>
|
||||
{{if .DevMode}} <option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub — без криптографии (демо)</option>{{end}}
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата Клиент L</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -186,19 +186,19 @@
|
||||
<p>Адрес web-сервиса ONYX и имя ключевого контейнера НРД.</p>
|
||||
|
||||
<div class="help-block">
|
||||
<strong>Что это?</strong> Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. У НРД есть 4 контура: <em>GUEST</em> (для разработки) и <em>TEST3</em> (предпродакшен), каждый в варианте ГОСТ или RSA.<br>
|
||||
<strong>Что это?</strong> Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. {{if .DevMode}}У НРД есть 4 контура: <em>GUEST</em> (для разработки) и <em>TEST3</em> (предпродакшен), каждый в варианте ГОСТ или RSA.{{else}}У НРД есть тестовый контур (<em>TEST3</em>) и боевой контур, каждый в варианте ГОСТ или RSA.{{end}}<br>
|
||||
<strong>Где взять?</strong> Дистрибутив ИШ и инструкции — на сайте НРД <a href="https://www.nsd.ru/workflow/system/programs/" target="_blank">nsd.ru/workflow/system/programs/</a>. Доступ к тестовым контурам выдаётся НРД по заявке (см. <code>DOC/instr_podkl_stend_v3.pdf</code>).
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Профиль <span class="tooltip" title="GUEST — гостевой контур для разработчиков (gost-gt.nsd.ru), TEST3 — тестовый предпродакшен (gost-t3.nsd.ru), prod — рабочий контур">?</span></label>
|
||||
<label>Профиль <span class="tooltip" title="{{if .DevMode}}GUEST — гостевой контур для разработчиков (gost-gt.nsd.ru), {{end}}TEST3 — тестовый контур НРД (gost-t3.nsd.ru), prod — боевой контур НРД">?</span></label>
|
||||
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>TEST3 · ГОСТ (рекомендуется для теста)</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>TEST3 · RSA</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA</option>
|
||||
<option value="prod" {{if eq .Settings.NSD.Profile "prod"}}selected{{end}}>prod — рабочий контур (осторожно)</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>TEST3 · ГОСТ — тестовый контур НРД</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>TEST3 · RSA — тестовый контур НРД</option>
|
||||
{{if or .DevMode (eq .Settings.NSD.Profile "guest-gost") (eq .Settings.NSD.Profile "guest-rsa")}} <option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA</option>{{end}}
|
||||
<option value="prod" {{if eq .Settings.NSD.Profile "prod"}}selected{{end}}>prod — боевой контур НРД (осторожно)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -228,14 +228,15 @@
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=3" class="btn" style="background:var(--card);text-decoration:none">← К шагу 3</a>
|
||||
{{if .Done.NSD}}<a href="/admin/wizard?step=5" class="btn" style="text-decoration:none">К шагу 5 →</a>{{else}}<a href="/admin/wizard?step=5&skip=nsd" class="btn" style="background:var(--card);text-decoration:none">Пропустить (mock-режим) →</a>{{end}}
|
||||
{{if .Done.NSD}}<a href="/admin/wizard?step=5" class="btn" style="text-decoration:none">К шагу 5 →</a>{{else}}<a href="/admin/wizard?step=5&skip=nsd" class="btn" style="background:var(--card);text-decoration:none">Пропустить ({{if .DevMode}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 5: Тест-ран ============= */}}
|
||||
{{/* ============= ШАГ 5: Тест-ран (режим разработки) / проверка подключений ============= */}}
|
||||
{{if eq .Step 5}}
|
||||
<div class="card">
|
||||
{{if .DevMode}}
|
||||
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 5. Тестовая заявка</h2>
|
||||
<p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p>
|
||||
|
||||
@@ -256,14 +257,19 @@
|
||||
{{if .Settings.LastTest.Message}}<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 5. Проверка подключений</h2>
|
||||
<p>Проверьте, что база данных, СКЗИ и шлюз НРД доступны: на странице «Статус» bj проверяет каждое подключение и показывает, что нужно исправить.</p>
|
||||
<a href="/admin/status" class="btn" style="background:var(--ok);font-size:15px;padding:10px 20px;text-decoration:none;display:inline-block;margin-top:12px">Открыть проверку подключений →</a>
|
||||
{{end}}
|
||||
|
||||
<h3 style="margin-top:18px">Итоговая сводка</h3>
|
||||
<table>
|
||||
<tr><td class="muted">PostgreSQL</td><td>{{if .Done.Postgres}}<span style="color:var(--ok)">настроен</span>{{else}}<span class="muted">in-memory</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Крипто-провайдер</td><td>{{if .Done.Crypto}}<span style="color:var(--ok)">{{.Settings.Crypto.Provider}}</span>{{else}}<span style="color:var(--err)">не настроен</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Сертификатов установлено</td><td>{{len .Certs}}</td></tr>
|
||||
<tr><td class="muted">ИШ НРД</td><td>{{if .Done.NSD}}<span style="color:var(--ok)">{{.Settings.NSD.Profile}}</span>{{else}}<span class="muted">mock-режим</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Тестовый прогон</td><td>{{if .Done.TestRun}}<span style="color:var(--ok)">пройден</span>{{else}}<span class="muted">не запускался</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">ИШ НРД</td><td>{{if .Done.NSD}}<span style="color:var(--ok)">{{.Settings.NSD.Profile}}</span>{{else}}<span class="muted">{{if .DevMode}}mock-режим{{else}}не настроен{{end}}</span>{{end}}</td></tr>
|
||||
{{if .DevMode}}<tr><td class="muted">Тестовый прогон</td><td>{{if .Done.TestRun}}<span style="color:var(--ok)">пройден</span>{{else}}<span class="muted">не запускался</span>{{end}}</td></tr>{{end}}
|
||||
</table>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
|
||||
@@ -287,7 +287,7 @@ label { font-size:13px; font-weight:500; }
|
||||
<a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a>
|
||||
</span>
|
||||
</span>
|
||||
{{end}}{{if and .DevMode (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
|
||||
{{end}}{{if can "view"}}<a href="/admin/manual" class="{{if eq .Active "manual"}}active{{end}}">Ручной ввод M2M</a>{{end}}{{if and .DevMode (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
|
||||
{{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}}
|
||||
{{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}">
|
||||
<span class="ddbtn">Обмен ▾</span>
|
||||
@@ -306,7 +306,7 @@ label { font-size:13px; font-weight:500; }
|
||||
<div class="topbar-right">
|
||||
{{with curUser}}<span class="user-chip" title="{{.RoleTitle}}{{if eq .Source "ad"}} · вход через AD{{end}}"><span class="user-name">{{if .Name}}{{.Name}}{{else}}{{.Login}}{{end}}</span><span class="user-role">{{.RoleTitle}}</span></span><a class="user-logout" href="/logout" title="Выйти">Выйти</a>{{else}}{{if authOpen}}<a class="user-logout" href="/admin/setup#users" title="Пользователи не созданы — вход открыт">без входа</a>{{end}}{{end}}
|
||||
<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
|
||||
<span class="pulse"></span>{{if .IsMockMode}}Эмуляция{{else}}НРД на связи{{end}}</span>
|
||||
<span class="pulse"></span>{{if .IsMockMode}}{{if .DevMode}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}}</span>
|
||||
<span class="clock">{{.Now}}</span>
|
||||
<button class="theme-toggle" id="theme-toggle" title="Светлая/тёмная тема" aria-label="Переключить тему"></button>
|
||||
</div>
|
||||
@@ -315,7 +315,7 @@ label { font-size:13px; font-weight:500; }
|
||||
{{if .IsMockMode}}
|
||||
<div class="banner-mock">
|
||||
<span class="dot warn" style="width:9px;height:9px;margin:0"></span>
|
||||
<div><strong style="color:var(--warn)">Режим эмуляции</strong> — реального обмена с НРД нет. <span class="muted">{{.MockReason}}</span></div>
|
||||
<div><strong style="color:var(--warn)">{{if .DevMode}}Режим эмуляции{{else}}Обмен с НРД не настроен{{end}}</strong> — {{if .DevMode}}реального обмена с НРД нет{{else}}поручения в НРД не отправляются{{end}}. <span class="muted">{{.MockReason}}</span></div>
|
||||
<a href="/admin/wizard" style="margin-left:auto">Открыть мастер настройки →</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user