From 1d0f904010fc9aa5c78b2af4cc2b1911b1b9fbc4 Mon Sep 17 00:00:00 2001
From: zuevav
Date: Thu, 17 Sep 2026 19:51:04 +0300
Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D1=8F=20=D0=BE=D1=80=D0=B3?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=20?=
=?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D1=85=20?=
=?UTF-8?q?(=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?=
=?UTF-8?q?=20=D0=9D=D0=A0=D0=94=20=D0=B8=D0=BB=D0=B8=20=D0=B2=D1=80=D1=83?=
=?UTF-8?q?=D1=87=D0=BD=D1=83=D1=8E)=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D1=88=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=20?=
=?UTF-8?q?=D0=98=D0=9D=D0=9D;=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20?=
=?UTF-8?q?=D0=B2=D0=B2=D0=BE=D0=B4:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20?=
=?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=B1?=
=?UTF-8?q?=D1=83=D0=BC=D0=B0=D0=B3=20=D0=B2=20DEPO,=20=D0=BE=D0=B4=D0=BD?=
=?UTF-8?q?=D0=B0=20=D0=B1=D1=83=D0=BC=D0=B0=D0=B3=D0=B0=20=D0=BD=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5 (1M context)
---
internal/lkgateway/incoming_answer.go | 7 +-
internal/lkgateway/lkstore.go | 5 +-
internal/lkgateway/manual.go | 7 +-
internal/lkgateway/manual_depo.go | 196 ++++++++++++++++++
internal/lkgateway/manual_handlers.go | 33 +--
internal/lkgateway/manual_test.go | 18 +-
internal/lkgateway/org.go | 116 +++++++++++
internal/lkgateway/quikcycle.go | 4 +-
internal/lkgateway/quikm2m.go | 35 ++--
internal/lkgateway/runtimeconfig.go | 7 +-
internal/lkgateway/server.go | 5 +
internal/lkgateway/setup.go | 19 +-
.../templates/admin_help_fancy_events.html | 4 +-
.../web/templates/admin_help_manual.html | 4 +-
.../web/templates/admin_help_quik.html | 6 +-
.../lkgateway/web/templates/admin_manual.html | 64 ++++--
.../lkgateway/web/templates/admin_setup.html | 36 +++-
17 files changed, 484 insertions(+), 82 deletions(-)
create mode 100644 internal/lkgateway/manual_depo.go
create mode 100644 internal/lkgateway/org.go
diff --git a/internal/lkgateway/incoming_answer.go b/internal/lkgateway/incoming_answer.go
index 6dd330c..300c256 100644
--- a/internal/lkgateway/incoming_answer.go
+++ b/internal/lkgateway/incoming_answer.go
@@ -151,11 +151,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
return nil
}
-// transferringINN — ИНН нашей организации (принимающий депозитарий в решении).
-// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000).
-func transferringINN(_ Settings) string {
- return "7703807489"
-}
+// transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
+func transferringINN(s Settings) string { return orgINN(s) }
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go
index b6e338f..f7edb7d 100644
--- a/internal/lkgateway/lkstore.go
+++ b/internal/lkgateway/lkstore.go
@@ -192,7 +192,10 @@ func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransfe
if doc.DocumentSeries != nil {
docSeries = string(*doc.DocumentSeries)
}
- ourINN := transferringINN(Settings{})
+ ourINN := ""
+ if globalRC != nil {
+ ourINN = transferringINN(globalRC.Snapshot())
+ }
opType := ""
switch ourINN {
case string(req.Data.TransferringDepository.INN):
diff --git a/internal/lkgateway/manual.go b/internal/lkgateway/manual.go
index 635d79b..4922702 100644
--- a/internal/lkgateway/manual.go
+++ b/internal/lkgateway/manual.go
@@ -329,8 +329,11 @@ func manualDataProblems(o *ManualOrder) []string {
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
p = append(p, "ИНН принимающего депозитария — 10 цифр")
}
- if len(o.Securities) == 0 {
- p = append(p, "нет ни одной бумаги")
+ switch {
+ case len(o.Securities) == 0:
+ p = append(p, "не указана бумага")
+ case len(o.Securities) > 1:
+ p = append(p, "в одном поручении — одна бумага: на каждую бумагу оформите отдельное поручение")
}
for i, s := range o.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
diff --git a/internal/lkgateway/manual_depo.go b/internal/lkgateway/manual_depo.go
new file mode 100644
index 0000000..a38a02e
--- /dev/null
+++ b/internal/lkgateway/manual_depo.go
@@ -0,0 +1,196 @@
+package lkgateway
+
+// manual_depo.go — подбор клиента и бумаг из DEPO для ручного ввода: поиск по ФИО или номеру
+// документа, открытые торговые счета, бумаги на торговом разделе с остатком.
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+// manualDepoAcc — открытый торговый счёт клиента.
+type manualDepoAcc struct {
+ ClientID int64 `json:"client_id"`
+ Number string `json:"number"`
+ Contract string `json:"contract,omitempty"`
+ IIS bool `json:"iis,omitempty"`
+}
+
+// manualDepoFace — клиент из картотеки DEPO.
+type manualDepoFace struct {
+ LastName string `json:"last_name"`
+ FirstName string `json:"first_name"`
+ MiddleName string `json:"middle_name,omitempty"`
+ Name string `json:"name"`
+ Birth string `json:"birth,omitempty"` // ГГГГ-ММ-ДД
+ DocType string `json:"doc_type,omitempty"`
+ DocSer string `json:"doc_ser,omitempty"`
+ DocNum string `json:"doc_num"`
+ DocDate string `json:"doc_date,omitempty"`
+ DocIssuer string `json:"doc_issuer,omitempty"`
+ Phone string `json:"phone,omitempty"`
+ Accounts []manualDepoAcc `json:"accounts"`
+}
+
+// manualDepoPos — бумага на торговом разделе клиента.
+type manualDepoPos struct {
+ ISIN string `json:"isin"`
+ Name string `json:"name"`
+ Qty float64 `json:"qty"`
+ Russian bool `json:"russian"`
+}
+
+// depoDocTypeNSD — вид документа DEPO → код справочника НРД (паспорт РФ в DEPO — 20).
+func depoDocTypeNSD(t int64) string {
+ if t == 20 {
+ return "21"
+ }
+ return ""
+}
+
+// manualDepoSearch — клиенты с открытым торговым счётом по ФИО (часть, без учёта регистра) или номеру документа.
+func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
+ q = strings.Join(strings.Fields(q), " ")
+ if len([]rune(q)) < 3 {
+ return nil, "введите не меньше 3 символов фамилии или номер документа"
+ }
+ if !manualDepoConfigured(rc) {
+ return nil, "учётная система (DEPO) не подключена"
+ }
+ fs := rc.Snapshot().Fancy
+ db, ctx, cancel, errS := depoOpen(fs)
+ if errS != "" {
+ return nil, errS
+ }
+ defer cancel()
+ defer db.Close()
+ digits := strings.ReplaceAll(q, " ", "")
+ rows, err := db.QueryContext(ctx, `SELECT TOP 20 f.ID, ISNULL(f.NAME,''), f.DATE_BIRTH, ISNULL(f.DOC_TYPE,0), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''),
+ f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'')
+ FROM OD_FACES f
+ WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d)
+ AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=f.ID AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
+ ORDER BY f.NAME`, sql.Named("q", q+"%"), sql.Named("d", digits))
+ if err != nil {
+ return nil, "поиск в DEPO: " + err.Error()
+ }
+ type faceRow struct {
+ id int64
+ f manualDepoFace
+ }
+ var found []faceRow
+ for rows.Next() {
+ var r faceRow
+ var birth, docDat sql.NullTime
+ var docType int64
+ var place, code string
+ if rows.Scan(&r.id, &r.f.Name, &birth, &docType, &r.f.DocSer, &r.f.DocNum, &docDat, &place, &code, &r.f.Phone) != nil {
+ continue
+ }
+ p := strings.Fields(r.f.Name)
+ if len(p) > 0 {
+ r.f.LastName = p[0]
+ }
+ if len(p) > 1 {
+ r.f.FirstName = p[1]
+ }
+ if len(p) > 2 {
+ r.f.MiddleName = strings.Join(p[2:], " ")
+ }
+ if birth.Valid {
+ r.f.Birth = birth.Time.Format("2006-01-02")
+ }
+ if docDat.Valid {
+ r.f.DocDate = docDat.Time.Format("2006-01-02")
+ }
+ r.f.DocType = depoDocTypeNSD(docType)
+ r.f.DocSer = strings.ReplaceAll(r.f.DocSer, " ", "")
+ r.f.DocIssuer = strings.TrimSpace(place)
+ if code = strings.TrimSpace(code); code != "" {
+ r.f.DocIssuer += ", код подразделения " + code
+ }
+ found = append(found, r)
+ }
+ rows.Close()
+ out := make([]manualDepoFace, 0, len(found))
+ for _, r := range found {
+ ar, err := db.QueryContext(ctx, `SELECT c.ID, ISNULL(c.NUMBER,''),
+ ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'')
+ FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`, sql.Named("f", r.id))
+ if err == nil {
+ for ar.Next() {
+ var a manualDepoAcc
+ if ar.Scan(&a.ClientID, &a.Number, &a.Contract) == nil {
+ r.f.Accounts = append(r.f.Accounts, a)
+ }
+ }
+ ar.Close()
+ }
+ for i := range r.f.Accounts {
+ ok, _ := depoAccountIIS(ctx, db, fs, r.f.Accounts[i].ClientID)
+ r.f.Accounts[i].IIS = !ok
+ }
+ out = append(out, r.f)
+ }
+ if len(out) == 0 {
+ return nil, "клиентов с открытым торговым счётом не найдено"
+ }
+ return out, ""
+}
+
+// manualDepoPositions — бумаги с положительным остатком на торговом разделе счёта (98070.04).
+func manualDepoPositions(rc *RuntimeConfig, clientID int64) ([]manualDepoPos, string) {
+ if !manualDepoConfigured(rc) {
+ return nil, "учётная система (DEPO) не подключена"
+ }
+ db, ctx, cancel, errS := depoOpen(rc.Snapshot().Fancy)
+ if errS != "" {
+ return nil, errS
+ }
+ defer cancel()
+ defer db.Close()
+ rows, err := db.QueryContext(ctx, `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float))
+ FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID
+ WHERE r.REG_1=@c AND r.BAL_ACC=@b
+ GROUP BY v.ISIN, v.NAME HAVING SUM(CAST(r.K AS float)) > 0 ORDER BY v.NAME`,
+ sql.Named("c", clientID), sql.Named("b", depoTradeBalAcc))
+ if err != nil {
+ return nil, "остатки в DEPO: " + err.Error()
+ }
+ defer rows.Close()
+ var out []manualDepoPos
+ for rows.Next() {
+ var p manualDepoPos
+ if rows.Scan(&p.ISIN, &p.Name, &p.Qty) == nil {
+ p.ISIN = strings.ToUpper(strings.TrimSpace(p.ISIN))
+ p.Russian = isRussianISIN(p.ISIN)
+ out = append(out, p)
+ }
+ }
+ return out, ""
+}
+
+// registerManualDepo — GET /admin/manual/depo/search?q= и /admin/manual/depo/positions?client=ID (JSON).
+func registerManualDepo(mux *http.ServeMux, rc *RuntimeConfig) {
+ reply := func(w http.ResponseWriter, v any, errS string) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(map[string]any{"items": v, "error": errS})
+ }
+ mux.HandleFunc("/admin/manual/depo/search", func(w http.ResponseWriter, r *http.Request) {
+ items, errS := manualDepoSearch(rc, r.URL.Query().Get("q"))
+ reply(w, items, errS)
+ })
+ mux.HandleFunc("/admin/manual/depo/positions", func(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.URL.Query().Get("client"), 10, 64)
+ if err != nil || id <= 0 {
+ reply(w, nil, "не указан счёт")
+ return
+ }
+ items, errS := manualDepoPositions(rc, id)
+ reply(w, items, errS)
+ })
+}
diff --git a/internal/lkgateway/manual_handlers.go b/internal/lkgateway/manual_handlers.go
index 3028e43..dc554f7 100644
--- a/internal/lkgateway/manual_handlers.go
+++ b/internal/lkgateway/manual_handlers.go
@@ -4,13 +4,11 @@ package lkgateway
import (
"context"
- "encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
- "os"
"strconv"
"strings"
"time"
@@ -53,26 +51,6 @@ func manualActor(w http.ResponseWriter) string {
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")
@@ -127,12 +105,14 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
render(w, tpl, d)
})
+ registerManualDepo(mux, rc)
+
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()
+ d.Handbook = handbookParticipants()
render(w, tpl, d)
})
@@ -220,10 +200,13 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
d.Mode = "print"
}
if o.Status == "draft" {
- d.Handbook = manualHandbook()
+ d.Handbook = handbookParticipants()
d.Problems = manualDataProblems(o)
d.Blockers = manualSendBlockers(rc, o)
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash()
+ if len(o.Securities) == 0 { // форма показывает строку бумаги всегда
+ o.Securities = []ManualSec{{}}
+ }
}
d.Progress = manualProgressFor(r.Context(), svc, o)
d.Auto = rc.Snapshot().M2MAuto.Enabled
@@ -366,7 +349,7 @@ func manualFromForm(r *http.Request) *ManualOrder {
if o.RecvINN != "" {
o.RecvCode = handbookCodeByINN(o.RecvINN)
if o.RecvName == "" {
- for _, p := range manualHandbook() {
+ for _, p := range handbookParticipants() {
if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) {
o.RecvName = p.Name
break
diff --git a/internal/lkgateway/manual_test.go b/internal/lkgateway/manual_test.go
index db6b543..b6c77d2 100644
--- a/internal/lkgateway/manual_test.go
+++ b/internal/lkgateway/manual_test.go
@@ -46,10 +46,14 @@ func TestManualSendBlockers(t *testing.T) {
t.Fatalf("автоблокировка недоступна: %v", b)
}
o.QuikMode = "none"
- o.Securities = append(o.Securities, ManualSec{ISIN: "US0378331005", Qty: "0"})
+ o.Securities = []ManualSec{{ISIN: "US0378331005", Qty: "0"}}
if b = manualSendBlockers(rc, o); len(b) != 2 {
t.Fatalf("иностранная бумага и нулевое количество: %v", b)
}
+ o.Securities = []ManualSec{{ISIN: "RU0009029540", Qty: "1"}, {ISIN: "RU000A0JUR61", Qty: "1"}}
+ if b = manualSendBlockers(rc, o); len(b) != 1 || !strings.Contains(b[0], "одна бумага") {
+ t.Fatalf("одно поручение — одна бумага: %v", b)
+ }
}
func TestManualQtyAndClaim(t *testing.T) {
@@ -142,3 +146,15 @@ func TestManualPages(t *testing.T) {
}
}
}
+
+func TestOrgIdentity(t *testing.T) {
+ var s Settings
+ s.NSD.DeponentCode = "MC0000000001"
+ if orgCode(s) != "MC0000000001" {
+ t.Fatalf("код по депозитарному коду: %q", orgCode(s))
+ }
+ s.NSD.OrgCode, s.NSD.OrgINN, s.NSD.OrgName = "mc0000000002", "7700000000", "Депозитарий"
+ if orgCode(s) != "MC0000000002" || orgINN(s) != "7700000000" || transferringINN(s) != "7700000000" || orgName(s) != "Депозитарий" {
+ t.Fatalf("организация из настроек: %q %q %q", orgCode(s), orgINN(s), orgName(s))
+ }
+}
diff --git a/internal/lkgateway/org.go b/internal/lkgateway/org.go
new file mode 100644
index 0000000..df46b48
--- /dev/null
+++ b/internal/lkgateway/org.go
@@ -0,0 +1,116 @@
+package lkgateway
+
+// org.go — наша организация (депозитарий) в M2M: код участника, ИНН, наименование.
+// Задаётся при настройке: выбором себя из справочника участников НРД или вручную. От неё зависят
+// ИНН передающего/зачисляющего депозитария в пакетах, код отправителя (канал ИШ) и код фирмы в QUIK.
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+)
+
+var regexpMCCode = regexp.MustCompile(`^[A-Z]{2}[0-9A-Z]{10}$`)
+
+// handbookParticipants — участники M2M из справочника НРД (без мест расчётов).
+func handbookParticipants() []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
+}
+
+// handbookByCode — участник по MC-коду.
+func handbookByCode(code string) (HandbookEntry, bool) {
+ code = strings.ToUpper(strings.TrimSpace(code))
+ if code == "" {
+ return HandbookEntry{}, false
+ }
+ for _, p := range handbookParticipants() {
+ if strings.EqualFold(p.Code, code) {
+ return p, true
+ }
+ }
+ return HandbookEntry{}, false
+}
+
+// orgCode — код участника M2M нашей организации: из настроек, иначе депозитарный код в НРД.
+func orgCode(s Settings) string {
+ return strings.ToUpper(strings.TrimSpace(pick(s.NSD.OrgCode, s.NSD.DeponentCode)))
+}
+
+// orgINN — ИНН нашей организации: из настроек, иначе по коду из справочника участников.
+func orgINN(s Settings) string {
+ if inn := strings.TrimSpace(s.NSD.OrgINN); inn != "" {
+ return inn
+ }
+ if p, ok := handbookByCode(orgCode(s)); ok {
+ return p.INN
+ }
+ return ""
+}
+
+// orgName — наименование нашей организации.
+func orgName(s Settings) string {
+ if n := strings.TrimSpace(s.NSD.OrgName); n != "" {
+ return n
+ }
+ if p, ok := handbookByCode(orgCode(s)); ok {
+ return p.Name
+ }
+ return ""
+}
+
+// saveOrg — POST /admin/setup/org: организация выбрана из справочника (hb=код) или введена вручную.
+func (h *setupHandlers) saveOrg(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ s := h.rc.Snapshot().NSD
+ code := strings.ToUpper(strings.TrimSpace(r.FormValue("org_code")))
+ inn := strings.TrimSpace(r.FormValue("org_inn"))
+ name := strings.TrimSpace(r.FormValue("org_name"))
+ if hb := strings.TrimSpace(r.FormValue("hb")); hb != "" {
+ if p, ok := handbookByCode(hb); ok {
+ code, inn, name = strings.ToUpper(p.Code), p.INN, pick(p.FullName, p.Name)
+ }
+ }
+ if inn != "" && !manualINNRe.MatchString(inn) {
+ setupFlash(w, r, "Организация: ИНН юридического лица — 10 цифр")
+ return
+ }
+ if code != "" && !regexpMCCode.MatchString(code) {
+ setupFlash(w, r, "Организация: код участника — вида MC0000000000")
+ return
+ }
+ changedCode := code != s.OrgCode
+ s.OrgCode, s.OrgINN, s.OrgName = code, inn, name
+ if s.DeponentCode == "" {
+ s.DeponentCode = code
+ }
+ if err := h.rc.UpdateNSD(s); err != nil {
+ setupFlash(w, r, "Организация: ошибка сохранения: "+err.Error())
+ return
+ }
+ msg := "Организация сохранена: " + pick(name, "—") + ", ИНН " + pick(inn, "—") + ", код " + pick(code, "—")
+ if changedCode {
+ msg += ". Код отправителя применится после перезапуска bj-server."
+ }
+ setupFlash(w, r, msg)
+}
diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go
index f787ca4..a4ce533 100644
--- a/internal/lkgateway/quikcycle.go
+++ b/internal/lkgateway/quikcycle.go
@@ -343,7 +343,7 @@ func quikExecNewInstr(ctx context.Context, s QuikMNPSettings, withdraw bool, ext
var aid sql.NullInt64
_, err = conn.ExecContext(cctx, quikNewInstrSQL,
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("ext", nullStr(extID)), sql.Named("cid", clientID), sql.Named("it", itype),
- sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID), sql.Named("acc", quikAccount(s)),
+ sql.Named("vol", qty), sql.Named("out", out), sql.Named("firm", quikFirmID()), sql.Named("acc", quikAccount(s)),
sql.Named("cm", comment), sql.Named("itid", itid), sql.Named("inn", nullStr(inn)), sql.Named("pass", passExpenses), sql.Named("isin", isin), sql.Named("qs", qs))
if err != nil {
return 0, fmt.Errorf("quik: sp_NewInstrSec: %s", quikErrorDetail(err))
@@ -372,7 +372,7 @@ func quikExecSign(ctx context.Context, s QuikMNPSettings, queryNo float64, clien
@IntSecCode=@isec, @BlockS=NULL, @PutReason=NULL, @FirmID=@firm, @SignFlag=@sf, @DealDate=NULL, @PayDate=NULL, @CheckType=1, @DocComment=@cm,
@AccountIn=NULL, @ClassCodeAfterTrans=NULL, @SecCodeAfterTrans=NULL, @QtyAfterTrans=NULL, @FirmAfterTrans=NULL, @BrokerComment=NULL`,
sql.Named("aid", sql.Out{Dest: &aid}), sql.Named("no", queryNo), sql.Named("cid", clientID), sql.Named("vol", qty), sql.Named("isec", secOut),
- sql.Named("firm", quikFirmID), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment))
+ sql.Named("firm", quikFirmID()), sql.Named("sf", quikSignFlagDepo), sql.Named("cm", comment))
if err != nil {
return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err))
}
diff --git a/internal/lkgateway/quikm2m.go b/internal/lkgateway/quikm2m.go
index 3adfd85..219dc2c 100644
--- a/internal/lkgateway/quikm2m.go
+++ b/internal/lkgateway/quikm2m.go
@@ -32,15 +32,14 @@ import (
// Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные).
const (
- quikFirmID = "MC0413600000" // наш код фирмы (таблица Firms, FirmId=2)
- quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М»
- quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М»
- quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК)
- quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК)
- quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода
- quikSecDeposit = "RURINM2M" // МНП-инструмент ввода
- quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий»
- quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка)
+ quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М»
+ quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М»
+ quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК)
+ quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК)
+ quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода
+ quikSecDeposit = "RURINM2M" // МНП-инструмент ввода
+ quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий»
+ quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка)
)
// errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока
@@ -124,8 +123,8 @@ func quikRegisterWithdrawal(ctx context.Context, s QuikMNPSettings, p QuikWithdr
sql.Named("ISIN", p.ISIN),
sql.Named("QtyScale", nullInt(p.QtyScale)),
sql.Named("Account", nullStr(p.Account)),
- sql.Named("QuikFirm", quikFirmID),
- sql.Named("FirmID", quikFirmID),
+ sql.Named("QuikFirm", quikFirmID()),
+ sql.Named("FirmID", quikFirmID()),
sql.Named("CanSplit", 0),
sql.Named("BlockS", p.Quantity),
sql.Named("M2MDepoRecipientINN", nullStr(p.RecipientINN)),
@@ -169,8 +168,8 @@ func quikRegisterDeposit(ctx context.Context, s QuikMNPSettings, clientID, isin,
sql.Named("QuikClassCode", nil),
sql.Named("ISIN", isin),
sql.Named("Account", nullStr(account)),
- sql.Named("QuikFirm", quikFirmID),
- sql.Named("FirmID", quikFirmID),
+ sql.Named("QuikFirm", quikFirmID()),
+ sql.Named("FirmID", quikFirmID()),
sql.Named("CanSplit", 0),
sql.Named("BlockS", qty),
sql.Named("CheckType", quikCheckViaAPI),
@@ -203,7 +202,7 @@ func quikSignWithdrawal(ctx context.Context, s QuikMNPSettings, queryNo float64,
sql.Named("ClientID", nullStr(clientID)),
sql.Named("Volume", nil), // при подписи всегда NULL
sql.Named("IntSecCode", quikSecWithdraw),
- sql.Named("FirmID", quikFirmID),
+ sql.Named("FirmID", quikFirmID()),
sql.Named("SignFlag", quikSignFlagDepo),
sql.Named("CheckType", quikCheckViaAPI),
)
@@ -280,3 +279,11 @@ func nullInt(v int) any {
}
return v
}
+
+// quikFirmID — код фирмы в QUIK (таблица Firms) = код участника нашей организации.
+func quikFirmID() string {
+ if globalRC == nil {
+ return ""
+ }
+ return orgCode(globalRC.Snapshot())
+}
diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go
index 2e84dab..8689814 100644
--- a/internal/lkgateway/runtimeconfig.go
+++ b/internal/lkgateway/runtimeconfig.go
@@ -221,7 +221,12 @@ type NSDSettings struct {
// Депозитарные реквизиты клиента — откуда списываются бумаги
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
- DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
+ // Наша организация — участник M2M (выбирается из справочника НРД или вводится при настройке).
+ OrgCode string `json:"org_code,omitempty"` // код участника (отправитель пакетов), напр. MC0000000000
+ OrgINN string `json:"org_inn,omitempty"` // ИНН — передающий/зачисляющий депозитарий в пакетах
+ OrgName string `json:"org_name,omitempty"`
+
+ DeponentCode string `json:"deponent_code"` // депозитарный код в НРД (место хранения)
AccountID string `json:"account_id"` // депозитарный счёт
SectionID string `json:"section_id"` // раздел депозитарного счёта
diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go
index 88af97b..29192ff 100644
--- a/internal/lkgateway/server.go
+++ b/internal/lkgateway/server.go
@@ -78,6 +78,11 @@ func NewServer(cfg ServerConfig) (*Server, error) {
return nil, err
}
+ // Код отправителя — наша организация из настроек (приоритет над BJ_M2M_SENDER).
+ if code := orgCode(rc.Snapshot()); code != "" {
+ cfg.DefaultSender = m2m.DeponentCode(code)
+ }
+
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
// mockSender остаётся не-nil только в mock-режиме — на нём висит
diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go
index fde2e53..5b8a763 100644
--- a/internal/lkgateway/setup.go
+++ b/internal/lkgateway/setup.go
@@ -84,6 +84,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/logo", h.saveLogo)
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
+ mux.HandleFunc("/admin/setup/org", h.saveOrg)
mux.HandleFunc("/admin/setup/lk", h.saveLK)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
@@ -298,10 +299,13 @@ type SetupData struct {
CurrentVersion string // версия bj-server (для раздела «Обновления»)
Components []release.ComponentState // компоненты и их версии (раздел «Обновления»)
UpdatesAvailable bool
- License LicenseStatus // статус лицензии
- Health []HealthStatus // здоровье фоновых компонентов
- AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
- NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ
+ License LicenseStatus // статус лицензии
+ Health []HealthStatus // здоровье фоновых компонентов
+ AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
+ NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ
+ Participants []HandbookEntry // справочник участников M2M (выбор своей организации)
+ OrgINN, OrgName string // наша организация (с учётом справочника)
+ OrgCode string
NotifyGroups []struct{ Key, Title string }
NotifyKinds []struct{ Key, Title string }
DepoJob DepoJobView
@@ -362,6 +366,10 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
Health: HealthSnapshot(),
AdvancedUnlocked: advancedUnlocked(req),
NSDCerts: nsdCertsView(s),
+ Participants: handbookParticipants(),
+ OrgINN: orgINN(s),
+ OrgName: orgName(s),
+ OrgCode: orgCode(s),
NotifyGroups: notifyGroups,
NotifyKinds: notifyKinds,
DepoJob: depoJobView(s.Fancy),
@@ -520,6 +528,9 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
Profile: strings.TrimSpace(r.FormValue("profile")),
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
+ OrgCode: h.rc.Snapshot().NSD.OrgCode, // организация — отдельной формой (saveOrg)
+ OrgINN: h.rc.Snapshot().NSD.OrgINN,
+ OrgName: h.rc.Snapshot().NSD.OrgName,
DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
AccountID: strings.TrimSpace(r.FormValue("account_id")),
SectionID: strings.TrimSpace(r.FormValue("section_id")),
diff --git a/internal/lkgateway/web/templates/admin_help_fancy_events.html b/internal/lkgateway/web/templates/admin_help_fancy_events.html
index 7b6c34f..55d0059 100644
--- a/internal/lkgateway/web/templates/admin_help_fancy_events.html
+++ b/internal/lkgateway/web/templates/admin_help_fancy_events.html
@@ -150,7 +150,7 @@ ORDER BY e.ID;
Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK
НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда CONTRAGENT_M2M_CODE из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.
-
Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — MC0413600000.
+
Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09).
Поле таблицы
Тип
Откуда у bj / что означает
@@ -201,7 +201,7 @@ ORDER BY e.ID;
INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC)
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'ВТНКЦ00000001');
+VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', 'M2M0000002675550', 'RU000A107UL4', 'RU000A107UL4', 18824, 1, '36MC0000000000F00', N'ВТНКЦ00000001');
INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_DATE)
VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_RECEIVED', '2026-09-17T09:21:26.000'); -- время получения M2MTR в bj
COMMIT;
diff --git a/internal/lkgateway/web/templates/admin_help_manual.html b/internal/lkgateway/web/templates/admin_help_manual.html
index adf47e1..09cf0e3 100644
--- a/internal/lkgateway/web/templates/admin_help_manual.html
+++ b/internal/lkgateway/web/templates/admin_help_manual.html
@@ -22,7 +22,7 @@
Примите поручение. Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.
Создайте поручение в системе: «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).
-
Найдите клиента в учётной системе кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.
+
Найдите клиента в учётной системе, если она подключена: в поле поиска введите фамилию (или ФИО), либо серию и номер документа, и нажмите «Найти в DEPO». Показываются клиенты с открытым торговым счётом. Нажмите на нужный счёт: система подставит ФИО, дату рождения, документ, кем и когда он выдан, счёт, договор и телефон, а ниже покажет бумаги клиента на торговом разделе с остатками. Выберите бумагу и укажите количество. Сверьте подставленные данные с поручением клиента.
Выполните сверку с DEPO кнопкой «Сохранить и сверить с DEPO». Система проверяет:
клиента по документу, ФИО и дате рождения;
@@ -48,7 +48,7 @@
ФИО, дата рождения
Как в документе. Дата рождения сверяется с анкетой депонента.
Счёт депо клиента
Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.
Договор, код в торговой системе
Для сведения и поиска клиента в торговой системе.
-
Ценные бумаги
ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.
+
Ценная бумага
Одно поручение — одна бумага: если клиент переводит несколько бумаг, на каждую оформляется отдельное поручение. ISIN (только российские выпуски, начинаются с RU) и количество; дробное количество допускается для паёв. Код НРД можно не указывать.
Принимающий депозитарий
Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.
Счёт у принимающего
Для сведения: принимающий депозитарий сам находит клиента по документу.
Где: QUIK Administrator → Модули → МНП → справочник «Классы инструментов». Документ: МОСТ п. 4.1, шаг 2, стр. 11.
-
Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: MC0413600000 (Фондовый рынок).
+
Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента, — код участника вашей организации (Фондовый рынок).
Шаг 3. Создать инструменты МНП
Где: QUIK Administrator → Модули → МНП → справочник «Инструменты». Документ: МОСТ п. 4.2, стр. 14.
@@ -68,7 +68,7 @@
Коды берите ровно такие, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.
-
Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме MC0413600000 добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).
+
Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме вашей организации добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).
Шаг 4. Включить типы поручений M2M
Где: QUIK Administrator → Модули → МНП → справочник «Типы поручений». Документ: МОСТ п. 4.3, стр. 16–22.
@@ -122,7 +122,7 @@
Клиент сам подал «Вывод ЦБ (M2M)» из QUIK
bj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящий
своё поручение «Активно», затем исполнено или снято с причиной
-
Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.
+
Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-001» → «BR001». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.
@@ -304,20 +318,17 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
-
+
-
3. Ценные бумаги
+
3. Ценная бумага
+
ISINКод НРДНаименованиеКоличество
-
- {{range .O.Securities}}
-
- {{end}}
-
-
-
Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.
+ {{$sec := index .O.Securities 0}}
+
+
Одно поручение — одна бумага: на каждую бумагу оформляется отдельное поручение. Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.
4. Принимающая сторона
@@ -345,10 +356,37 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
К списку
-
{{end}}
diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html
index a234232..58682be 100644
--- a/internal/lkgateway/web/templates/admin_setup.html
+++ b/internal/lkgateway/web/templates/admin_setup.html
@@ -199,13 +199,13 @@
Инструкция для администратора QUIK — что настроить в QuikAdmin для M2M · документы ARQA.
@@ -472,6 +472,28 @@
НРД
{{template "verline" (.Versions.Get "ish")}}
+
+
Наша организация
+
Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах, код отправителя и код фирмы в QUIK. Выберите себя из справочника участников НРД или введите данные вручную.
+ {{if and .OrgINN .OrgCode}}
{{if .OrgName}}{{.OrgName}}{{else}}—{{end}} · ИНН {{.OrgINN}} · код {{.OrgCode}}{{if not .Settings.NSD.OrgINN}} (ИНН взят из справочника по коду){{end}}
+ {{else}}
Организация не задана — переводы не будут сформированы.
{{end}}
+
+
Автоматический режим M2M
Без участия оператора, чтобы соблюдать сроки: ответ на входящие переводы по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), подача поручения 16/1, как только НРД пришлёт черновик после нашего согласия, и подача поручения 16 по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.
@@ -504,7 +526,7 @@
Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.