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).

@@ -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 @@
  1. Примите поручение. Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.
  2. Создайте поручение в системе: «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).
  3. -
  4. Найдите клиента в учётной системе кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.
  5. +
  6. Найдите клиента в учётной системе, если она подключена: в поле поиска введите фамилию (или ФИО), либо серию и номер документа, и нажмите «Найти в DEPO». Показываются клиенты с открытым торговым счётом. Нажмите на нужный счёт: система подставит ФИО, дату рождения, документ, кем и когда он выдан, счёт, договор и телефон, а ниже покажет бумаги клиента на торговом разделе с остатками. Выберите бумагу и укажите количество. Сверьте подставленные данные с поручением клиента.
  7. Выполните сверку с DEPO кнопкой «Сохранить и сверить с DEPO». Система проверяет:
    • клиента по документу, ФИО и дате рождения;
    • @@ -48,7 +48,7 @@
- + diff --git a/internal/lkgateway/web/templates/admin_help_quik.html b/internal/lkgateway/web/templates/admin_help_quik.html index 0e0781b..b024b33 100644 --- a/internal/lkgateway/web/templates/admin_help_quik.html +++ b/internal/lkgateway/web/templates/admin_help_quik.html @@ -56,7 +56,7 @@

Шаг 2. Добавить классы в плагин МНП

Где: QUIK Administrator → Модули → МНП → справочник «Классы инструментов». Документ: МОСТ п. 4.1, шаг 2, стр. 11.

-

Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: MC0413600000 (Фондовый рынок).

+

Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента, — код участника вашей организации (Фондовый рынок).

Шаг 3. Создать инструменты МНП

Где: QUIK Administrator → Модули → МНП → справочник «Инструменты». Документ: МОСТ п. 4.2, стр. 14.

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

Коды берите ровно такие, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.

-

Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме MC0413600000 добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).

+

Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме вашей организации добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).

Шаг 4. Включить типы поручений M2M

Где: QUIK Administrator → Модули → МНП → справочник «Типы поручений». Документ: МОСТ п. 4.3, стр. 16–22.

@@ -122,7 +122,7 @@ Клиент сам подал «Вывод ЦБ (M2M)» из QUIKbj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящийсвоё поручение «Активно», затем исполнено или снято с причиной -

Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.

+

Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-001» → «BR001». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.

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

    diff --git a/internal/lkgateway/web/templates/admin_manual.html b/internal/lkgateway/web/templates/admin_manual.html index db77ebf..e550f8b 100644 --- a/internal/lkgateway/web/templates/admin_manual.html +++ b/internal/lkgateway/web/templates/admin_manual.html @@ -31,6 +31,12 @@ .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-find{background:var(--card-2);border:1px solid var(--border);border-radius:10px;padding:10px;margin-bottom:10px} + .mf-find input{padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px} + .mf-res:empty{display:none}.mf-res{margin:8px 0} + .mf-hit{display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:center;padding:7px 0;border-top:1px solid var(--border)} + .mf-pos{display:block;width:100%;text-align:left;padding:7px 10px;margin:3px 0;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);cursor:pointer;font-size:13px} + .mf-pos:hover:not([disabled]){border-color:var(--accent)}.mf-pos[disabled]{opacity:.5;cursor:not-allowed} .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{ @@ -291,12 +297,20 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
    2. Клиент (как в поручении и анкете депонента)
    + {{if .DepoOn}} +
    +
    + + +
    +
    +
    + {{end}}
    -
    {{if .DepoOn}}{{end}}
    @@ -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.

    + +