Своя организация в настройках (справочник НРД или вручную) вместо зашитого ИНН; ручной ввод: поиск клиента и бумаг в DEPO, одна бумага на поручение

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 19:51:04 +03:00
parent e4de1ab4d8
commit 1d0f904010
17 changed files with 484 additions and 82 deletions
+2 -5
View File
@@ -151,11 +151,8 @@ func answerIncoming(ctx context.Context, deps incomingDeps, id string, decisions
return nil return nil
} }
// transferringINN — ИНН нашей организации (принимающий депозитарий в решении). // transferringINN — ИНН нашей организации (передающий депозитарий в запросе, принимающий — в решении).
// ИНН ООО ИК «Фонтвьель» (участник M2M MC0413600000). func transferringINN(s Settings) string { return orgINN(s) }
func transferringINN(_ Settings) string {
return "7703807489"
}
// packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге // packageDecisions — пакет принимается только целиком (решение 16.09.2026): если хотя бы по одной бумаге
// отказ, отказываем по всем; принятым бумагам ставится код первой отказной. // отказ, отказываем по всем; принятым бумагам ставится код первой отказной.
+4 -1
View File
@@ -192,7 +192,10 @@ func lkInsertRequest(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransfe
if doc.DocumentSeries != nil { if doc.DocumentSeries != nil {
docSeries = string(*doc.DocumentSeries) docSeries = string(*doc.DocumentSeries)
} }
ourINN := transferringINN(Settings{}) ourINN := ""
if globalRC != nil {
ourINN = transferringINN(globalRC.Snapshot())
}
opType := "" opType := ""
switch ourINN { switch ourINN {
case string(req.Data.TransferringDepository.INN): case string(req.Data.TransferringDepository.INN):
+5 -2
View File
@@ -329,8 +329,11 @@ func manualDataProblems(o *ManualOrder) []string {
if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) { if !manualINNRe.MatchString(strings.TrimSpace(o.RecvINN)) {
p = append(p, "ИНН принимающего депозитария — 10 цифр") p = append(p, "ИНН принимающего депозитария — 10 цифр")
} }
if len(o.Securities) == 0 { switch {
p = append(p, "нет ни одной бумаги") case len(o.Securities) == 0:
p = append(p, "не указана бумага")
case len(o.Securities) > 1:
p = append(p, "в одном поручении — одна бумага: на каждую бумагу оформите отдельное поручение")
} }
for i, s := range o.Securities { for i, s := range o.Securities {
isin := strings.ToUpper(strings.TrimSpace(s.ISIN)) isin := strings.ToUpper(strings.TrimSpace(s.ISIN))
+196
View File
@@ -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)
})
}
+8 -25
View File
@@ -4,13 +4,11 @@ package lkgateway
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -53,26 +51,6 @@ func manualActor(w http.ResponseWriter) string {
return "без входа" 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) { func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, tpl, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS,
"web/templates/layout.html", "web/templates/admin_manual.html") "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) render(w, tpl, d)
}) })
registerManualDepo(mux, rc)
mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/admin/manual/new", func(w http.ResponseWriter, r *http.Request) {
d := base("Новое поручение") d := base("Новое поручение")
d.Mode = "edit" d.Mode = "edit"
d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"), d.O = &ManualOrder{Status: "draft", DocType: "21", SettlePlace: "nrd", PaperDate: time.Now().Format("2006-01-02"),
ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}} ReceivedVia: "лично в офисе", Securities: []ManualSec{{}}}
d.Handbook = manualHandbook() d.Handbook = handbookParticipants()
render(w, tpl, d) render(w, tpl, d)
}) })
@@ -220,10 +200,13 @@ func registerManual(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
d.Mode = "print" d.Mode = "print"
} }
if o.Status == "draft" { if o.Status == "draft" {
d.Handbook = manualHandbook() d.Handbook = handbookParticipants()
d.Problems = manualDataProblems(o) d.Problems = manualDataProblems(o)
d.Blockers = manualSendBlockers(rc, o) d.Blockers = manualSendBlockers(rc, o)
d.DepoStale = o.Depo != nil && o.Depo.Hash != o.dataHash() 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.Progress = manualProgressFor(r.Context(), svc, o)
d.Auto = rc.Snapshot().M2MAuto.Enabled d.Auto = rc.Snapshot().M2MAuto.Enabled
@@ -366,7 +349,7 @@ func manualFromForm(r *http.Request) *ManualOrder {
if o.RecvINN != "" { if o.RecvINN != "" {
o.RecvCode = handbookCodeByINN(o.RecvINN) o.RecvCode = handbookCodeByINN(o.RecvINN)
if o.RecvName == "" { if o.RecvName == "" {
for _, p := range manualHandbook() { for _, p := range handbookParticipants() {
if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) { if p.INN == o.RecvINN && (o.RecvCode == "" || p.Code == o.RecvCode) {
o.RecvName = p.Name o.RecvName = p.Name
break break
+17 -1
View File
@@ -46,10 +46,14 @@ func TestManualSendBlockers(t *testing.T) {
t.Fatalf("автоблокировка недоступна: %v", b) t.Fatalf("автоблокировка недоступна: %v", b)
} }
o.QuikMode = "none" 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 { if b = manualSendBlockers(rc, o); len(b) != 2 {
t.Fatalf("иностранная бумага и нулевое количество: %v", b) 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) { 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))
}
}
+116
View File
@@ -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)
}
+2 -2
View File
@@ -343,7 +343,7 @@ func quikExecNewInstr(ctx context.Context, s QuikMNPSettings, withdraw bool, ext
var aid sql.NullInt64 var aid sql.NullInt64
_, err = conn.ExecContext(cctx, quikNewInstrSQL, _, 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("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)) 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 { if err != nil {
return 0, fmt.Errorf("quik: sp_NewInstrSec: %s", quikErrorDetail(err)) 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, @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`, @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("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 { if err != nil {
return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err)) return 0, fmt.Errorf("quik: sp_SignInstrSec: %s", quikErrorDetail(err))
} }
+21 -14
View File
@@ -32,15 +32,14 @@ import (
// Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные). // Идентификаторы M2M на стороне QUIK (фиксированы инструкцией + наши данные).
const ( const (
quikFirmID = "MC0413600000" // наш код фирмы (таблица Firms, FirmId=2) quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М»
quikTypeWithdrawM2M = 100 // InstrTypeID «Вывод ЦБ М2М» quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М»
quikTypeDepositM2M = 101 // InstrTypeID «Ввод ЦБ М2М» quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК)
quikBaseTypeWithdraw = 9 // базовый Type для вывода (Поставка ЦБ из ДКК) quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК)
quikBaseTypeDeposit = 8 // базовый Type для ввода (Получение ЦБ в ДКК) quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода
quikSecWithdraw = "RUROUTM2M" // МНП-инструмент вывода quikSecDeposit = "RURINM2M" // МНП-инструмент ввода
quikSecDeposit = "RURINM2M" // МНП-инструмент ввода quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий»
quikSignFlagDepo = 16 // FLG_DEPO — подпись «Депозитарий» quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка)
quikCheckViaAPI = 1 // @CheckType=1 — через QuikAPI (проверка+блокировка)
) )
// errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока // errQuikWritesDisabled — предохранитель: пишущие операции запрещены, пока
@@ -124,8 +123,8 @@ func quikRegisterWithdrawal(ctx context.Context, s QuikMNPSettings, p QuikWithdr
sql.Named("ISIN", p.ISIN), sql.Named("ISIN", p.ISIN),
sql.Named("QtyScale", nullInt(p.QtyScale)), sql.Named("QtyScale", nullInt(p.QtyScale)),
sql.Named("Account", nullStr(p.Account)), sql.Named("Account", nullStr(p.Account)),
sql.Named("QuikFirm", quikFirmID), sql.Named("QuikFirm", quikFirmID()),
sql.Named("FirmID", quikFirmID), sql.Named("FirmID", quikFirmID()),
sql.Named("CanSplit", 0), sql.Named("CanSplit", 0),
sql.Named("BlockS", p.Quantity), sql.Named("BlockS", p.Quantity),
sql.Named("M2MDepoRecipientINN", nullStr(p.RecipientINN)), 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("QuikClassCode", nil),
sql.Named("ISIN", isin), sql.Named("ISIN", isin),
sql.Named("Account", nullStr(account)), sql.Named("Account", nullStr(account)),
sql.Named("QuikFirm", quikFirmID), sql.Named("QuikFirm", quikFirmID()),
sql.Named("FirmID", quikFirmID), sql.Named("FirmID", quikFirmID()),
sql.Named("CanSplit", 0), sql.Named("CanSplit", 0),
sql.Named("BlockS", qty), sql.Named("BlockS", qty),
sql.Named("CheckType", quikCheckViaAPI), sql.Named("CheckType", quikCheckViaAPI),
@@ -203,7 +202,7 @@ func quikSignWithdrawal(ctx context.Context, s QuikMNPSettings, queryNo float64,
sql.Named("ClientID", nullStr(clientID)), sql.Named("ClientID", nullStr(clientID)),
sql.Named("Volume", nil), // при подписи всегда NULL sql.Named("Volume", nil), // при подписи всегда NULL
sql.Named("IntSecCode", quikSecWithdraw), sql.Named("IntSecCode", quikSecWithdraw),
sql.Named("FirmID", quikFirmID), sql.Named("FirmID", quikFirmID()),
sql.Named("SignFlag", quikSignFlagDepo), sql.Named("SignFlag", quikSignFlagDepo),
sql.Named("CheckType", quikCheckViaAPI), sql.Named("CheckType", quikCheckViaAPI),
) )
@@ -280,3 +279,11 @@ func nullInt(v int) any {
} }
return v return v
} }
// quikFirmID — код фирмы в QUIK (таблица Firms) = код участника нашей организации.
func quikFirmID() string {
if globalRC == nil {
return ""
}
return orgCode(globalRC.Snapshot())
}
+6 -1
View File
@@ -221,7 +221,12 @@ type NSDSettings struct {
// Депозитарные реквизиты клиента — откуда списываются бумаги // Депозитарные реквизиты клиента — откуда списываются бумаги
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД. // (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"` // депозитарный счёт AccountID string `json:"account_id"` // депозитарный счёт
SectionID string `json:"section_id"` // раздел депозитарного счёта SectionID string `json:"section_id"` // раздел депозитарного счёта
+5
View File
@@ -78,6 +78,11 @@ func NewServer(cfg ServerConfig) (*Server, error) {
return nil, err return nil, err
} }
// Код отправителя — наша организация из настроек (приоритет над BJ_M2M_SENDER).
if code := orgCode(rc.Snapshot()); code != "" {
cfg.DefaultSender = m2m.DeponentCode(code)
}
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL — // Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция. // используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
// mockSender остаётся не-nil только в mock-режиме — на нём висит // mockSender остаётся не-nil только в mock-режиме — на нём висит
+15 -4
View File
@@ -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", h.saveLogo)
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo) mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
mux.HandleFunc("/admin/setup/nsd", h.saveNSD) 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", h.saveLK)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
@@ -298,10 +299,13 @@ type SetupData struct {
CurrentVersion string // версия bj-server (для раздела «Обновления») CurrentVersion string // версия bj-server (для раздела «Обновления»)
Components []release.ComponentState // компоненты и их версии (раздел «Обновления») Components []release.ComponentState // компоненты и их версии (раздел «Обновления»)
UpdatesAvailable bool UpdatesAvailable bool
License LicenseStatus // статус лицензии License LicenseStatus // статус лицензии
Health []HealthStatus // здоровье фоновых компонентов Health []HealthStatus // здоровье фоновых компонентов
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy) AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ NSDCerts NSDCertsView // сертификаты НРД в справочнике ИШ
Participants []HandbookEntry // справочник участников M2M (выбор своей организации)
OrgINN, OrgName string // наша организация (с учётом справочника)
OrgCode string
NotifyGroups []struct{ Key, Title string } NotifyGroups []struct{ Key, Title string }
NotifyKinds []struct{ Key, Title string } NotifyKinds []struct{ Key, Title string }
DepoJob DepoJobView DepoJob DepoJobView
@@ -362,6 +366,10 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
Health: HealthSnapshot(), Health: HealthSnapshot(),
AdvancedUnlocked: advancedUnlocked(req), AdvancedUnlocked: advancedUnlocked(req),
NSDCerts: nsdCertsView(s), NSDCerts: nsdCertsView(s),
Participants: handbookParticipants(),
OrgINN: orgINN(s),
OrgName: orgName(s),
OrgCode: orgCode(s),
NotifyGroups: notifyGroups, NotifyGroups: notifyGroups,
NotifyKinds: notifyKinds, NotifyKinds: notifyKinds,
DepoJob: depoJobView(s.Fancy), DepoJob: depoJobView(s.Fancy),
@@ -520,6 +528,9 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
Profile: strings.TrimSpace(r.FormValue("profile")), Profile: strings.TrimSpace(r.FormValue("profile")),
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")), IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
KeyContainer: strings.TrimSpace(r.FormValue("key_container")), 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")), DeponentCode: strings.TrimSpace(r.FormValue("deponent_code")),
AccountID: strings.TrimSpace(r.FormValue("account_id")), AccountID: strings.TrimSpace(r.FormValue("account_id")),
SectionID: strings.TrimSpace(r.FormValue("section_id")), SectionID: strings.TrimSpace(r.FormValue("section_id")),
@@ -150,7 +150,7 @@ ORDER BY e.ID;</pre>
<h2 id="handbook">Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK</h2> <h2 id="handbook">Справочник участников M2M (M2MTH) → таблица FTV_M2M_HANDBOOK</h2>
<p>НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда <code>CONTRAGENT_M2M_CODE</code> из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.</p> <p>НРД ведёт справочник участников переводов по MOEX МОСТ: кто депозитарий, кто брокер, их коды участника и ИНН, плюс места расчётов. bj получает его от НРД (сообщение M2MTransferHandbook) при старте и обновляет раз в сутки; по нему bj определяет контрагента по ИНН из перевода и наполняет справочник депозитариев в QUIK МНП. Предлагаем отдавать его в DEPO целиком в отдельную таблицу — тогда <code>CONTRAGENT_M2M_CODE</code> из FTV_M2M раскрывается в ИНН и наименование, и по нему можно заводить контрагентов в DEPO.</p>
<p class="muted">Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09). Наш код участника — <code>MC0413600000</code>.</p> <p class="muted">Сейчас в справочнике 158 участников и 2 мест расчётов (получено 2026-09-09).</p>
<table> <table>
<thead><tr><th style="width:190px">Поле таблицы</th><th style="width:130px">Тип</th><th>Откуда у bj / что означает</th></tr></thead> <thead><tr><th style="width:190px">Поле таблицы</th><th style="width:130px">Тип</th><th>Откуда у bj / что означает</th></tr></thead>
<tbody> <tbody>
@@ -201,7 +201,7 @@ ORDER BY e.ID;</pre>
INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) 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'); 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) 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) 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 VALUES ('5325B860-3265-49B6-986D-FA84FB41FE27', NULL, 'BJ_RECEIVED', '2026-09-17T09:21:26.000'); -- время получения M2MTR в bj
COMMIT; COMMIT;
@@ -22,7 +22,7 @@
<ol> <ol>
<li><b>Примите поручение.</b> Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.</li> <li><b>Примите поручение.</b> Проверьте, что поручение подписано клиентом или представителем, датировано и содержит реквизиты бумаг и принимающего депозитария. Если действует представитель, проверьте доверенность.</li>
<li><b>Создайте поручение в системе:</b> «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).</li> <li><b>Создайте поручение в системе:</b> «Ручной ввод M2M» → «Новое поручение». Заполните поля по бумажному поручению (описание полей — ниже).</li>
<li><b>Найдите клиента в учётной системе</b> кнопкой «Найти в DEPO по документу», если учётная система подключена. Пустые поля (ФИО, дата рождения, счёт, наименования бумаг) заполнятся из учётной системы. Сверьте их с поручением.</li> <li><b>Найдите клиента в учётной системе</b>, если она подключена: в поле поиска введите фамилию (или ФИО), либо серию и номер документа, и нажмите «Найти в DEPO». Показываются клиенты с открытым торговым счётом. Нажмите на нужный счёт: система подставит ФИО, дату рождения, документ, кем и когда он выдан, счёт, договор и телефон, а ниже покажет бумаги клиента на торговом разделе с остатками. Выберите бумагу и укажите количество. Сверьте подставленные данные с поручением клиента.</li>
<li><b>Выполните сверку с DEPO</b> кнопкой «Сохранить и сверить с DEPO». Система проверяет: <li><b>Выполните сверку с DEPO</b> кнопкой «Сохранить и сверить с DEPO». Система проверяет:
<ul> <ul>
<li>клиента по документу, ФИО и дате рождения;</li> <li>клиента по документу, ФИО и дате рождения;</li>
@@ -48,7 +48,7 @@
<tr><td>ФИО, дата рождения</td><td>Как в документе. Дата рождения сверяется с анкетой депонента.</td></tr> <tr><td>ФИО, дата рождения</td><td>Как в документе. Дата рождения сверяется с анкетой депонента.</td></tr>
<tr><td>Счёт депо клиента</td><td>Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.</td></tr> <tr><td>Счёт депо клиента</td><td>Торговый счёт депо, с которого списываются бумаги. Он должен принадлежать клиенту и быть открыт.</td></tr>
<tr><td>Договор, код в торговой системе</td><td>Для сведения и поиска клиента в торговой системе.</td></tr> <tr><td>Договор, код в торговой системе</td><td>Для сведения и поиска клиента в торговой системе.</td></tr>
<tr><td>Ценные бумаги</td><td>ISIN (только российские выпуски, начинаются с RU) и количество. Дробное количество допускается для паёв. Код НРД можно не указывать.</td></tr> <tr><td>Ценная бумага</td><td><b>Одно поручение — одна бумага</b>: если клиент переводит несколько бумаг, на каждую оформляется отдельное поручение. ISIN (только российские выпуски, начинаются с RU) и количество; дробное количество допускается для паёв. Код НРД можно не указывать.</td></tr>
<tr><td>Принимающий депозитарий</td><td>Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.</td></tr> <tr><td>Принимающий депозитарий</td><td>Выбирается из справочника участников M2M, который bj получает от НРД. Если депозитария нет в справочнике, перевод через MOEX МОСТ невозможен.</td></tr>
<tr><td>Счёт у принимающего</td><td>Для сведения: принимающий депозитарий сам находит клиента по документу.</td></tr> <tr><td>Счёт у принимающего</td><td>Для сведения: принимающий депозитарий сам находит клиента по документу.</td></tr>
<tr><td>Место расчётов</td><td>НРД или СПБ Банк — как в поручении клиента.</td></tr> <tr><td>Место расчётов</td><td>НРД или СПБ Банк — как в поручении клиента.</td></tr>
@@ -56,7 +56,7 @@
<h2 id="s2">Шаг 2. Добавить классы в плагин МНП</h2> <h2 id="s2">Шаг 2. Добавить классы в плагин МНП</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Классы инструментов»</strong>. <em>Документ:</em> МОСТ п. 4.1, шаг 2, стр. 11.</p> <p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Классы инструментов»</strong>. <em>Документ:</em> МОСТ п. 4.1, шаг 2, стр. 11.</p>
<p>Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента: <code>MC0413600000</code> (Фондовый рынок).</p> <p>Добавьте оба класса, созданных на шаге 1, и на каждом выберите фирму, по которой будет меняться позиция клиента, — код участника вашей организации (Фондовый рынок).</p>
<h2 id="s3">Шаг 3. Создать инструменты МНП</h2> <h2 id="s3">Шаг 3. Создать инструменты МНП</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Инструменты»</strong>. <em>Документ:</em> МОСТ п. 4.2, стр. 14.</p> <p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Инструменты»</strong>. <em>Документ:</em> МОСТ п. 4.2, стр. 14.</p>
@@ -68,7 +68,7 @@
</tbody> </tbody>
</table> </table>
<p>Коды берите <strong>ровно такие</strong>, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.</p> <p>Коды берите <strong>ровно такие</strong>, как в документе: bj передаёт их в вызовах процедур. Если инструменты уже созданы под другими кодами (например, по имени класса), сообщите нам коды — мы подставим их в настройках.</p>
<p>Затем на <strong>сервере QUIK</strong> в настройках Библиотеки расчёта лимитов по фирме <code>MC0413600000</code> добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).</p> <p>Затем на <strong>сервере QUIK</strong> в настройках Библиотеки расчёта лимитов по фирме вашей организации добавьте эти инструменты и привяжите к валюте (рубли) и тегу расчётов, с которого списывается комиссия депозитария: раздел «Инструменты» / «Параметры учёта неторговых поручений» (МОСТ п. 4.2, стр. 14, второй абзац).</p>
<h2 id="s4">Шаг 4. Включить типы поручений M2M</h2> <h2 id="s4">Шаг 4. Включить типы поручений M2M</h2>
<p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Типы поручений»</strong>. <em>Документ:</em> МОСТ п. 4.3, стр. 16–22.</p> <p><em>Где:</em> QUIK Administrator → Модули → МНП → справочник <strong>«Типы поручений»</strong>. <em>Документ:</em> МОСТ п. 4.3, стр. 16–22.</p>
@@ -122,7 +122,7 @@
<tr><td>Клиент сам подал «Вывод ЦБ (M2M)» из QUIK</td><td>bj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящий</td><td>своё поручение «Активно», затем исполнено или снято с причиной</td></tr> <tr><td>Клиент сам подал «Вывод ЦБ (M2M)» из QUIK</td><td>bj видит его в экспорте, оповещает оператора; после создания заявки поручение привязывается в карточке, дальше — как обычный исходящий</td><td>своё поручение «Активно», затем исполнено или снято с причиной</td></tr>
</tbody> </tbody>
</table> </table>
<p class="muted">Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-859» → «BR859». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>; при ошибке приходит оповещение и есть кнопки для ручного повтора.</p> <p class="muted">Код клиента QUIK bj берёт из номера портфеля клиента в DEPO: «БР-001» → «BR001». Исключения задаются в той же форме настроек. Каждый шаг с ответом модуля виден в карточке перевода и на странице <a href="/admin/quik">QUIK МНП</a>; при ошибке приходит оповещение и есть кнопки для ручного повтора.</p>
<h2 id="verify">Как проверить, что всё готово</h2> <h2 id="verify">Как проверить, что всё готово</h2>
<ol> <ol>
@@ -31,6 +31,12 @@
.mf-kv{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:4px 14px;font-size:13.5px} .mf-kv{display:grid;grid-template-columns:minmax(150px,220px) 1fr;gap:4px 14px;font-size:13.5px}
.mf-kv .k{color:var(--muted)} .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} .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{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} .mf-sign div{border-top:1px solid #000;padding-top:4px;font-size:12px}
@media print{ @media print{
@@ -291,12 +297,20 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
</div> </div>
<div class="mf-h">2. Клиент (как в поручении и анкете депонента)</div> <div class="mf-h">2. Клиент (как в поручении и анкете депонента)</div>
{{if .DepoOn}}
<div class="mf-find">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input id="dq" placeholder="Фамилия (или ФИО), либо серия и номер документа" style="flex:1;min-width:260px" onkeydown="if(event.key==='Enter'){event.preventDefault();mfFind()}">
<button type="button" class="btn" onclick="mfFind()">Найти в DEPO</button>
</div>
<div id="dres" class="mf-res"></div>
</div>
{{end}}
<div class="mf-grid"> <div class="mf-grid">
<div><label class="mf-req">Вид документа</label> <div><label class="mf-req">Вид документа</label>
<select name="doc_type">{{$dt := .O.DocType}}{{range .DocTypes}}<option value="{{.Code}}" {{if eq $dt .Code}}selected{{end}}>{{.Code}} — {{.Title}}</option>{{end}}</select></div> <select name="doc_type">{{$dt := .O.DocType}}{{range .DocTypes}}<option value="{{.Code}}" {{if eq $dt .Code}}selected{{end}}>{{.Code}} — {{.Title}}</option>{{end}}</select></div>
<div><label>Серия</label><input name="doc_series" value="{{.O.DocSeries}}" placeholder="4 цифры для паспорта РФ"></div> <div><label>Серия</label><input name="doc_series" value="{{.O.DocSeries}}" placeholder="4 цифры для паспорта РФ"></div>
<div><label class="mf-req">Номер</label><input name="doc_number" value="{{.O.DocNumber}}"></div> <div><label class="mf-req">Номер</label><input name="doc_number" value="{{.O.DocNumber}}"></div>
<div style="display:flex;align-items:flex-end">{{if .DepoOn}}<button type="submit" name="do" value="lookup" class="btn btn-secondary" style="width:100%" title="Найти клиента в учётной системе по документу и заполнить пустые поля">Найти в DEPO по документу</button>{{end}}</div>
<div><label>Дата выдачи</label><input type="date" name="doc_issue_date" value="{{.O.DocIssueDate}}"></div> <div><label>Дата выдачи</label><input type="date" name="doc_issue_date" value="{{.O.DocIssueDate}}"></div>
<div class="wide"><label>Кем выдан</label><input name="doc_issuer" value="{{.O.DocIssuer}}"></div> <div class="wide"><label>Кем выдан</label><input name="doc_issuer" value="{{.O.DocIssuer}}"></div>
<div><label class="mf-req">Фамилия</label><input name="last_name" value="{{.O.LastName}}"></div> <div><label class="mf-req">Фамилия</label><input name="last_name" value="{{.O.LastName}}"></div>
@@ -304,20 +318,17 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
<div><label>Отчество</label><input name="middle_name" value="{{.O.MiddleName}}"></div> <div><label>Отчество</label><input name="middle_name" value="{{.O.MiddleName}}"></div>
<div><label class="mf-req">Дата рождения</label><input type="date" name="birth_date" value="{{.O.BirthDate}}"></div> <div><label class="mf-req">Дата рождения</label><input type="date" name="birth_date" value="{{.O.BirthDate}}"></div>
<div><label>Договор (брокерский / депозитарный)</label><input name="contract" value="{{.O.Contract}}"></div> <div><label>Договор (брокерский / депозитарный)</label><input name="contract" value="{{.O.Contract}}"></div>
<div><label class="mf-req">Счёт депо клиента</label><input name="account" value="{{.O.Account}}" placeholder="торговый счёт депо"></div> <div><label class="mf-req">Счёт депо клиента</label><input name="account" id="f_account" value="{{.O.Account}}" placeholder="торговый счёт депо"></div>
<div><label>Код клиента в торговой системе</label><input name="quik_code" value="{{.O.QuikCode}}"></div> <div><label>Код клиента в торговой системе</label><input name="quik_code" value="{{.O.QuikCode}}"></div>
<div><label>Телефон для связи</label><input name="phone" value="{{.O.Phone}}"></div> <div><label>Телефон для связи</label><input name="phone" value="{{.O.Phone}}"></div>
</div> </div>
<div class="mf-h">3. Ценные бумаги</div> <div class="mf-h">3. Ценная бумага</div>
<div id="dpos" class="mf-res"></div>
<div class="mf-sec mf-note" style="margin-bottom:2px"><span class="mf-req">ISIN</span><span>Код НРД</span><span class="nm">Наименование</span><span class="mf-req">Количество</span><span></span></div> <div class="mf-sec mf-note" style="margin-bottom:2px"><span class="mf-req">ISIN</span><span>Код НРД</span><span class="nm">Наименование</span><span class="mf-req">Количество</span><span></span></div>
<div id="secs"> {{$sec := index .O.Securities 0}}
{{range .O.Securities}} <div class="mf-sec"><input name="sec_isin" id="f_isin" value="{{$sec.ISIN}}" placeholder="RU…" maxlength="12"><input name="sec_code" value="{{$sec.Code}}" placeholder="необязательно"><input class="nm" name="sec_name" id="f_sname" value="{{$sec.Name}}" placeholder="заполнится из DEPO"><input name="sec_qty" id="f_qty" value="{{$sec.Qty}}" inputmode="decimal"><span></span></div>
<div class="mf-sec"><input name="sec_isin" value="{{.ISIN}}" placeholder="RU…" maxlength="12"><input name="sec_code" value="{{.Code}}" placeholder="необязательно"><input class="nm" name="sec_name" value="{{.Name}}" placeholder="заполнится из DEPO"><input name="sec_qty" value="{{.Qty}}" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div> <p class="mf-note">Одно поручение — одна бумага: на каждую бумагу оформляется отдельное поручение. Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.</p>
{{end}}
</div>
<button type="button" class="btn btn-secondary" onclick="mfAdd()" style="margin-top:4px">+ Бумага</button>
<p class="mf-note">Через MOEX МОСТ переводятся только российские выпуски (ISIN начинается с RU). Дробное количество — для паёв, через точку или запятую.</p>
<div class="mf-h">4. Принимающая сторона</div> <div class="mf-h">4. Принимающая сторона</div>
<div class="mf-grid"> <div class="mf-grid">
@@ -345,10 +356,37 @@ function mfRerun(){const f=document.getElementById('mf');if(f&&f.closest('detail
<a href="/admin/manual" class="btn btn-secondary">К списку</a> <a href="/admin/manual" class="btn btn-secondary">К списку</a>
</div> </div>
</form> </form>
<template id="secRow"><div class="mf-sec"><input name="sec_isin" placeholder="RU…" maxlength="12"><input name="sec_code" placeholder="необязательно"><input class="nm" name="sec_name" placeholder="заполнится из DEPO"><input name="sec_qty" inputmode="decimal"><button type="button" class="x" onclick="mfDel(this)" title="Убрать">×</button></div></template>
<script> <script>
function mfAdd(){document.getElementById('secs').appendChild(document.getElementById('secRow').content.cloneNode(true))} const mfEsc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function mfDel(b){const rows=document.querySelectorAll('#secs .mf-sec');if(rows.length>1){b.parentNode.remove()}else{b.parentNode.querySelectorAll('input').forEach(i=>i.value='')}} let mfFaces=[];
function mfSet(n,v){const e=document.querySelector('#mf [name="'+n+'"]');if(e&&v!==undefined&&v!==null)e.value=v}
async function mfFind(){
const q=document.getElementById('dq').value.trim(),box=document.getElementById('dres');
box.innerHTML='<span class="mf-note">Поиск…</span>';
try{const r=await (await fetch('/admin/manual/depo/search?q='+encodeURIComponent(q))).json();
if(r.error){box.innerHTML='<span class="dc-bad">'+mfEsc(r.error)+'</span>';return}
mfFaces=r.items||[];
box.innerHTML=mfFaces.map((f,i)=>'<div class="mf-hit"><div><b>'+mfEsc(f.name)+'</b><div class="mf-note">'+(f.birth?'д.р. '+mfEsc(f.birth)+' · ':'')+'документ '+mfEsc(f.doc_ser)+' '+mfEsc(f.doc_num)+'</div></div><div style="display:flex;gap:6px;flex-wrap:wrap">'+
(f.accounts||[]).map((a,j)=>'<button type="button" class="btn btn-secondary" onclick="mfPick('+i+','+j+')"'+(a.iis?' title="ИИС — перевод через M2M не выполняется"':'')+'>'+mfEsc(a.number)+(a.contract?' · '+mfEsc(a.contract):'')+(a.iis?' · ИИС':'')+'</button>').join('')+'</div></div>').join('');
}catch(e){box.innerHTML='<span class="dc-bad">Поиск не выполнен: '+mfEsc(e)+'</span>'}
}
async function mfPick(i,j){
const f=mfFaces[i],a=f.accounts[j];
mfSet('last_name',f.last_name);mfSet('first_name',f.first_name);mfSet('middle_name',f.middle_name||'');mfSet('birth_date',f.birth||'');
if(f.doc_type)mfSet('doc_type',f.doc_type);
mfSet('doc_series',f.doc_ser||'');mfSet('doc_number',f.doc_num);mfSet('doc_issue_date',f.doc_date||'');mfSet('doc_issuer',f.doc_issuer||'');
mfSet('account',a.number);mfSet('contract',a.contract||'');if(f.phone)mfSet('phone',f.phone);
document.getElementById('dres').innerHTML='<span class="dc-ok">Подставлено из DEPO: '+mfEsc(f.name)+', счёт '+mfEsc(a.number)+'. Сверьте с поручением клиента.</span>';
const box=document.getElementById('dpos');box.innerHTML='<span class="mf-note">Загрузка бумаг клиента…</span>';
try{const r=await (await fetch('/admin/manual/depo/positions?client='+a.client_id)).json();
if(r.error){box.innerHTML='<span class="dc-bad">'+mfEsc(r.error)+'</span>';return}
const items=r.items||[];mfPos=items;
if(!items.length){box.innerHTML='<span class="mf-note">На торговом разделе счёта '+mfEsc(a.number)+' бумаг нет.</span>';return}
box.innerHTML='<div class="mf-note" style="margin-bottom:4px">Бумаги на торговом разделе '+mfEsc(a.number)+' — выберите одну:</div>'+items.map((p,k)=>'<button type="button" class="mf-pos"'+(p.russian?'':' disabled title="иностранный выпуск — через M2M не переводится"')+' onclick="mfSec('+k+')"><b>'+mfEsc(p.isin)+'</b> '+mfEsc(p.name)+' <span class="mf-note">· '+p.qty+' шт.</span></button>').join('');
}catch(e){box.innerHTML='<span class="dc-bad">'+mfEsc(e)+'</span>'}
}
let mfPos=[];
function mfSec(k){const p=mfPos[k];document.getElementById('f_isin').value=p.isin;document.getElementById('f_sname').value=p.name;const q=document.getElementById('f_qty');q.placeholder='доступно '+p.qty;q.focus()}
function mfPickHB(s){const o=s.options[s.selectedIndex];if(!o.value)return;document.getElementById('recv_inn').value=o.value;document.getElementById('recv_name').value=o.dataset.name||''} function mfPickHB(s){const o=s.options[s.selectedIndex];if(!o.value)return;document.getElementById('recv_inn').value=o.value;document.getElementById('recv_name').value=o.dataset.name||''}
</script> </script>
{{end}} {{end}}
@@ -199,13 +199,13 @@
<a href="/admin/help/quik"><strong>Инструкция для администратора QUIK</strong></a> — что настроить в QuikAdmin для M2M · <a href="/admin/docs/">документы ARQA</a>.</p> <a href="/admin/help/quik"><strong>Инструкция для администратора QUIK</strong></a> — что настроить в QuikAdmin для M2M · <a href="/admin/docs/">документы ARQA</a>.</p>
<form method="post" action="/admin/setup/quik" style="margin-top:12px;display:grid;gap:10px;max-width:560px"> <form method="post" action="/admin/setup/quik" style="margin-top:12px;display:grid;gap:10px;max-width:560px">
<label>Сервер (host или host\instance) <label>Сервер (host или host\instance)
<input type="text" name="server" value="{{.Settings.Quik.Server}}" placeholder="91.209.122.88" style="width:100%" required></label> <input type="text" name="server" value="{{.Settings.Quik.Server}}" placeholder="адрес SQL Server QUIK" style="width:100%" required></label>
<label>Порт <label>Порт
<input type="number" name="port" value="{{if .Settings.Quik.Port}}{{.Settings.Quik.Port}}{{else}}1433{{end}}" placeholder="8849" style="width:100%"></label> <input type="number" name="port" value="{{if .Settings.Quik.Port}}{{.Settings.Quik.Port}}{{else}}1433{{end}}" placeholder="8849" style="width:100%"></label>
<label>Имя БД (если требуется, иначе оставьте пустым) <label>Имя БД (если требуется, иначе оставьте пустым)
<input type="text" name="database" value="{{.Settings.Quik.Database}}" placeholder="(по умолчанию)" style="width:100%"></label> <input type="text" name="database" value="{{.Settings.Quik.Database}}" placeholder="(по умолчанию)" style="width:100%"></label>
<label>Логин (роль InstrEu) <label>Логин (роль InstrEu)
<input type="text" name="user" value="{{.Settings.Quik.User}}" placeholder="BROKER_88" style="width:100%" required></label> <input type="text" name="user" value="{{.Settings.Quik.User}}" placeholder="логин SQL" style="width:100%" required></label>
<label>Пароль <label>Пароль
<input type="password" name="password" placeholder="{{if .Settings.Quik.Password}}•••••• — оставьте пустым, чтобы не менять{{else}}пароль SQL-логина{{end}}" autocomplete="off" style="width:100%"></label> <input type="password" name="password" placeholder="{{if .Settings.Quik.Password}}•••••• — оставьте пустым, чтобы не менять{{else}}пароль SQL-логина{{end}}" autocomplete="off" style="width:100%"></label>
<label>Шифрование канала <label>Шифрование канала
@@ -254,7 +254,7 @@
<label>МНП-инструмент ввода <span class="muted">(пусто = из класса INSTRINM2M)</span> <label>МНП-инструмент ввода <span class="muted">(пусто = из класса INSTRINM2M)</span>
<input type="text" name="sec_in" value="{{.Settings.Quik.SecIn}}" placeholder="RURINM2M" style="width:100%"></label> <input type="text" name="sec_in" value="{{.Settings.Quik.SecIn}}" placeholder="RURINM2M" style="width:100%"></label>
</div> </div>
<label>Исключения кодов клиентов <span class="muted">(по умолчанию «БР-859» → «BR859»; строка «номер портфеля=код QUIK»)</span> <label>Исключения кодов клиентов <span class="muted">(по умолчанию «БР-001» → «BR001»; строка «номер портфеля=код QUIK»)</span>
<textarea name="client_code_map" rows="3" style="width:100%;font-family:ui-monospace,monospace;font-size:12px" placeholder="БР-4-4=BR44">{{quikCodeMap .Settings.Quik.ClientCodeMap}}</textarea></label> <textarea name="client_code_map" rows="3" style="width:100%;font-family:ui-monospace,monospace;font-size:12px" placeholder="БР-4-4=BR44">{{quikCodeMap .Settings.Quik.ClientCodeMap}}</textarea></label>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button></div> <div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form> </form>
@@ -472,6 +472,28 @@
<section class="settings-section" id="sec-nsd"> <section class="settings-section" id="sec-nsd">
<h1>НРД</h1> <h1>НРД</h1>
{{template "verline" (.Versions.Get "ish")}} {{template "verline" (.Versions.Get "ish")}}
<div class="card" id="sec-org">
<h2><span class="dot {{if and .OrgINN .OrgCode}}ok{{else}}err{{end}}"></span>Наша организация</h2>
<p class="muted">Депозитарий — участник MOEX МОСТ, от имени которого bj отправляет и принимает переводы: ИНН передающего и зачисляющего депозитария в пакетах, код отправителя и код фирмы в QUIK. Выберите себя из справочника участников НРД или введите данные вручную.</p>
{{if and .OrgINN .OrgCode}}<p style="margin:8px 0 0"><b>{{if .OrgName}}{{.OrgName}}{{else}}—{{end}}</b> · ИНН <code>{{.OrgINN}}</code> · код <code>{{.OrgCode}}</code>{{if not .Settings.NSD.OrgINN}} <span class="muted">(ИНН взят из справочника по коду)</span>{{end}}</p>
{{else}}<p style="margin:8px 0 0;color:var(--err)">Организация не задана — переводы не будут сформированы.</p>{{end}}
<form method="post" action="/admin/setup/org" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<input type="hidden" name="section" value="sec-org">
<label>Из справочника участников M2M</label>
<select name="hb" onchange="var o=this.options[this.selectedIndex];if(o.value){this.form.org_code.value=o.value;this.form.org_inn.value=o.dataset.inn;this.form.org_name.value=o.dataset.name}">
<option value="">— выбрать себя —</option>
{{$code := .OrgCode}}{{range .Participants}}<option value="{{.Code}}" data-inn="{{.INN}}" data-name="{{if .FullName}}{{.FullName}}{{else}}{{.Name}}{{end}}" {{if eq .Code $code}}selected{{end}}>{{.Name}} · ИНН {{.INN}} · {{.Code}}</option>{{end}}
</select>
{{if not .Participants}}<span class="muted" style="font-size:12px">Справочник участников ещё не получен — запросите его в «Диагностике» или введите данные вручную.</span>{{end}}
<label>Код участника</label>
<input type="text" name="org_code" value="{{.Settings.NSD.OrgCode}}" placeholder="MC0000000000" maxlength="12">
<label>ИНН</label>
<input type="text" name="org_inn" value="{{.Settings.NSD.OrgINN}}" maxlength="10" inputmode="numeric">
<label>Наименование</label>
<input type="text" name="org_name" value="{{.Settings.NSD.OrgName}}">
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
</div>
<div class="card" id="m2m-auto"> <div class="card" id="m2m-auto">
<h2><span class="dot {{if .Settings.M2MAuto.Enabled}}ok{{else}}warn{{end}}"></span>Автоматический режим M2M</h2> <h2><span class="dot {{if .Settings.M2MAuto.Enabled}}ok{{else}}warn{{end}}"></span>Автоматический режим M2M</h2>
<p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p> <p class="muted">Без участия оператора, чтобы соблюдать сроки: <strong>ответ на входящие переводы</strong> по свежей сверке с DEPO (пакет принимается только целиком; бумага для квалифицированных инвесторов при неполной или непрочитанной квалификации клиента — отказ M2M08), <strong>подача поручения 16/1</strong>, как только НРД пришлёт черновик после нашего согласия, и <strong>подача поручения 16</strong> по исходящим после согласия контрагента. Если DEPO или ИШ недоступны — bj повторяет каждые 3 минуты и шлёт оповещение; без сверки ничего не принимается. Обрабатывается только то, что пришло не раньше чем за сутки до включения. Кнопки в карточках остаются для ручной работы.</p>
@@ -504,7 +526,7 @@
<hr style="border:none;border-top:1px solid var(--border);margin:6px 0"> <hr style="border:none;border-top:1px solid var(--border);margin:6px 0">
<p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p> <p class="muted" style="margin:0">Депозитарные реквизиты (откуда списываются бумаги) — из договора/письма НРД. Нужны для формирования заявки на перевод.</p>
<label>Депозитарный код</label> <label>Депозитарный код</label>
<input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="напр. MC0413600000"> <input type="text" name="deponent_code" value="{{.Settings.NSD.DeponentCode}}" placeholder="MC0000000000">
<label>Депозитарный счёт</label> <label>Депозитарный счёт</label>
<input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт"> <input type="text" name="account_id" value="{{.Settings.NSD.AccountID}}" placeholder="депозитарный счёт">
<label>Раздел счёта</label> <label>Раздел счёта</label>
@@ -549,14 +571,14 @@
<div> <div>
<label>Адрес ЛК</label> <label>Адрес ЛК</label>
<div style="display:flex;gap:8px;align-items:center;margin-top:4px"> <div style="display:flex;gap:8px;align-items:center;margin-top:4px">
<input type="text" name="lk_public_url" value="{{.Settings.LK.PublicURL}}" placeholder="https://fontvielle.ef.winr.pro" style="flex:1"> <input type="text" name="lk_public_url" value="{{.Settings.LK.PublicURL}}" placeholder="https://lk.example.ru" style="flex:1">
{{if .Settings.LK.PublicURL}}<a class="btn btn-secondary" href="{{.Settings.LK.PublicURL}}" target="_blank" rel="noopener">Открыть →</a>{{end}} {{if .Settings.LK.PublicURL}}<a class="btn btn-secondary" href="{{.Settings.LK.PublicURL}}" target="_blank" rel="noopener">Открыть →</a>{{end}}
</div> </div>
<span class="muted" style="font-size:12px">{{if .DevMode}}Показывается ссылкой на дашборде для первичного теста.{{else}}Адрес, по которому клиенты входят в личный кабинет.{{end}}</span> <span class="muted" style="font-size:12px">{{if .DevMode}}Показывается ссылкой на дашборде для первичного теста.{{else}}Адрес, по которому клиенты входят в личный кабинет.{{end}}</span>
</div> </div>
<div> <div>
<label>Callback URL <span class="muted" style="font-weight:400">(куда bj шлёт статус заявки)</span></label> <label>Callback URL <span class="muted" style="font-weight:400">(куда bj шлёт статус заявки)</span></label>
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="https://fontvielle.ef.winr.pro" style="margin-top:4px"> <input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="https://lk.example.ru" style="margin-top:4px">
<span class="muted" style="font-size:12px">{{if not .Settings.LK.CallbackURL}}Не настроен — уведомления в ЛК отключены. Для обмена с НРД не требуется.{{end}}</span> <span class="muted" style="font-size:12px">{{if not .Settings.LK.CallbackURL}}Не настроен — уведомления в ЛК отключены. Для обмена с НРД не требуется.{{end}}</span>
</div> </div>
<div><button type="submit" class="btn">Сохранить и проверить</button></div> <div><button type="submit" class="btn">Сохранить и проверить</button></div>
@@ -577,7 +599,7 @@
</div> </div>
<form method="post" action="/admin/setup/lk-integration" style="display:grid;gap:9px"> <form method="post" action="/admin/setup/lk-integration" style="display:grid;gap:9px">
<input type="hidden" name="env" value="test"> <input type="hidden" name="env" value="test">
<div><label>Адрес API</label><input type="text" name="base_url" value="{{.Settings.LK.Test.BaseURL}}" placeholder="https://fontvielle.ef.winr.pro/manager/api" style="margin-top:3px"></div> <div><label>Адрес API</label><input type="text" name="base_url" value="{{.Settings.LK.Test.BaseURL}}" placeholder="https://lk.example.ru/manager/api" style="margin-top:3px"></div>
<div><label>Логин</label><input type="text" name="login" value="{{.Settings.LK.Test.Login}}" autocomplete="off" style="margin-top:3px"></div> <div><label>Логин</label><input type="text" name="login" value="{{.Settings.LK.Test.Login}}" autocomplete="off" style="margin-top:3px"></div>
<div><label>Пароль</label><input type="password" name="password" value="" placeholder="{{if .Settings.LK.Test.Password}}•••••• (не менять — оставьте пустым){{end}}" autocomplete="new-password" style="margin-top:3px"></div> <div><label>Пароль</label><input type="password" name="password" value="" placeholder="{{if .Settings.LK.Test.Password}}•••••• (не менять — оставьте пустым){{end}}" autocomplete="new-password" style="margin-top:3px"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:2px"> <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:2px">