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;
НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда CONTRAGENT_M2M_CODE из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.
Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — MC0413600000.
Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09).
| Поле таблицы | Тип | Откуда у bj / что означает |
|---|---|---|
| ФИО, дата рождения | Как в документе. Дата рождения сверяется с анкетой депонента. | |
| Счёт депо клиента | Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт. | |
| Договор, код в торговой системе | Для сведения и поиска клиента в торговой системе. | |
| Ценные бумаги | ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать. | |
| Ценная бумага | Одно поручение — одна бумага: если клиент переводит несколько бумаг, на каждую оформляется отдельное поручение. ISIN (только российские выпуски, начинаются с RU) и количество; дробное количество допускается для паёв. Код НРД можно не указывать. | |
| Принимающий депозитарий | Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен. | |
| Счёт у принимающего | Для сведения: принимающий депозитарий сам находит клиента по документу. | |
| Место расчётов | НРД или СПБ Банк — как в поручении клиента. |
Коды берите ровно такие, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.
-Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме MC0413600000 добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).
Затем на сервере QUIK в настройках Библиотеки расчёта лимитов по фирме вашей организации добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).
Где: QUIK Administrator → Модули → МНП → справочник «Типы поручений». Документ: МОСТ п. 4.3, стр. 16–22.
@@ -122,7 +122,7 @@Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.
+Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-001» → «BR001». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице QUIK МНП; при ошибке приходит оповещение и есть кнопки для ручного повтора.
Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.
+ {{$sec := index .O.Securities 0}} +Одно поручение — одна бумага: на каждую бумагу оформляется отдельное поручение. Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.
Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах, код отправителя и код фирмы в QUIK. Выберите себя из справочника участников НРД или введите данные вручную.
+ {{if and .OrgINN .OrgCode}}{{if .OrgName}}{{.OrgName}}{{else}}—{{end}} · ИНН {{.OrgINN}} · код {{.OrgCode}}{{if not .Settings.NSD.OrgINN}} (ИНН взят из справочника по коду){{end}}
Организация не задана — переводы не будут сформированы.
{{end}} + +Без участия оператора, чтобы соблюдать сроки: ответ на входящие переводы по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), подача поручения 16/1, как только НРД пришлёт черновик после нашего согласия, и подача поручения 16 по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.
@@ -504,7 +526,7 @@Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.
- + @@ -549,14 +571,14 @@