diff --git a/internal/lkgateway/lkpfclients_live_test.go b/internal/lkgateway/lkpfclients_live_test.go new file mode 100644 index 0000000..985cb2f --- /dev/null +++ b/internal/lkgateway/lkpfclients_live_test.go @@ -0,0 +1,22 @@ +//go:build live + +package lkgateway + +import ( + "context" + "fmt" + "os" + "testing" +) + +func TestLKPortfolioClientsLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + list, err := lkPortfolioClients(context.Background(), rc, os.Getenv("Q")) + fmt.Println(err) + for _, c := range list { + fmt.Printf("%+v\n", c) + } +} diff --git a/internal/lkgateway/lkportfolio.go b/internal/lkgateway/lkportfolio.go index 279172d..baa40c7 100644 --- a/internal/lkgateway/lkportfolio.go +++ b/internal/lkgateway/lkportfolio.go @@ -71,6 +71,7 @@ func lkPaperType(cfi, name string) string { // (пусто — все). skipped — бумаги, которые не отправляются, с причиной. func lkPortfolioLines(ctx context.Context, rc *RuntimeConfig, depoPort string, onlyISIN map[string]bool) (lines []LKPortfolioLine, skipped []string, err error) { fs := rc.Snapshot().Fancy + ps := rc.Snapshot().LK.PortfolioSync pos, err := depoClientPositions(ctx, fs, depoPort) if err != nil { return nil, nil, fmt.Errorf("DEPO: %w", err) @@ -106,7 +107,7 @@ func lkPortfolioLines(ctx context.Context, rc *RuntimeConfig, depoPort string, o } lines = append(lines, LKPortfolioLine{ ISIN: isin, Name: strings.TrimSpace(p.SecurityName), RegNumber: strings.TrimSpace(reg), Count: p.Quantity, - DepoAccount: p.AccountNum, Section: lkPortfolioSection, Storage: lkPortfolioStorage, PaperType: lkPaperType(cfi, p.SecurityName), + DepoAccount: p.AccountNum, Section: ps.section(), Storage: ps.storage(), PaperType: lkPaperType(cfi, p.SecurityName), }) } return lines, skipped, nil @@ -167,6 +168,24 @@ type LKPortfolioSyncSettings struct { Enabled bool `json:"enabled"` // Pairs — «договор депо в ЛК = портфель DEPO» через «;», напр. «ДД-816=БР-859». Pairs string `json:"pairs,omitempty"` + // Именование у депозитариев разное — всё настраивается; пусто — как у Фонтвьель. + AccountPrefix string `json:"account_prefix,omitempty"` // префикс торгового счёта в DEPO: «ВТНКЦ» + ContractTemplate string `json:"contract_template,omitempty"` // номер договора депо в ЛК: «ДД-{n}», {n} — номер счёта без префикса и ведущих нулей + Section string `json:"section,omitempty"` // раздел счёта депо в ЛК: «Основной» + Storage string `json:"storage,omitempty"` // место хранения: «НРД» +} + +func (s LKPortfolioSyncSettings) accountPrefix() string { + return pickStr(strings.TrimSpace(s.AccountPrefix), "ВТНКЦ") +} +func (s LKPortfolioSyncSettings) contractTemplate() string { + return pickStr(strings.TrimSpace(s.ContractTemplate), "ДД-{n}") +} +func (s LKPortfolioSyncSettings) section() string { + return pickStr(strings.TrimSpace(s.Section), lkPortfolioSection) +} +func (s LKPortfolioSyncSettings) storage() string { + return pickStr(strings.TrimSpace(s.Storage), lkPortfolioStorage) } type lkPortfolioPair struct{ LK, Depo string } @@ -263,24 +282,159 @@ func lkPortfolioStates() []LKPortfolioSyncState { // registerLKPortfolioSync — POST /admin/setup/lk-portfolio: настройки и «синхронизировать сейчас». func registerLKPortfolioSync(mux *http.ServeMux, rc *RuntimeConfig) { + // GET /admin/setup/lk-portfolio/clients?q= — поиск клиента DEPO для заливки портфеля. + mux.HandleFunc("/admin/setup/lk-portfolio/clients", func(w http.ResponseWriter, r *http.Request) { + list, err := lkPortfolioClients(r.Context(), rc, r.URL.Query().Get("q")) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err != nil { + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + _ = json.NewEncoder(w).Encode(list) + }) mux.HandleFunc("/admin/setup/lk-portfolio", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } - if r.FormValue("action") == "sync" { + lk := rc.Snapshot().LK + if r.Form.Has("pairs") { + lk.PortfolioSync.Pairs = strings.TrimSpace(r.FormValue("pairs")) + } + if r.Form.Has("naming") { // именование у депозитария + lk.PortfolioSync.AccountPrefix = strings.TrimSpace(r.FormValue("account_prefix")) + lk.PortfolioSync.ContractTemplate = strings.TrimSpace(r.FormValue("contract_template")) + lk.PortfolioSync.Section = strings.TrimSpace(r.FormValue("section")) + lk.PortfolioSync.Storage = strings.TrimSpace(r.FormValue("storage")) + } + switch r.FormValue("action") { + case "on": + lk.PortfolioSync.Enabled = lk.PortfolioSync.Pairs != "" + case "off": + lk.PortfolioSync.Enabled = false + } + if err := rc.UpdateLK(lk); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // выбранный клиент: «Залить сейчас» (только его, для теста) или «Добавить в постоянное заполнение» + if port, lkc := strings.TrimSpace(r.FormValue("client_port")), strings.TrimSpace(r.FormValue("client_lk")); port != "" && lkc != "" { + pr := lkPortfolioPair{LK: lkc, Depo: port} + if r.FormValue("action") == "client_add" { + have := false + for _, x := range lk.PortfolioSync.pairs() { + if x.LK == lkc { + have = true + } + } + if !have { + lk.PortfolioSync.Pairs = strings.Trim(lk.PortfolioSync.Pairs+"; "+lkc+"="+port, "; ") + _ = rc.UpdateLK(lk) + } + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + lkPortfolioPushPair(ctx, rc, pr) + cancel() + http.Redirect(w, r, "/admin/setup#lk-portfolio", http.StatusSeeOther) + return + } + if a := r.FormValue("action"); a == "sync" || a == "on" { // включили или «Заполнить сейчас» — сразу отправить ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) lkPortfolioSyncTick(ctx, rc, true) cancel() - } else { - lk := rc.Snapshot().LK - lk.PortfolioSync.Enabled = r.FormValue("enabled") != "" - lk.PortfolioSync.Pairs = strings.TrimSpace(r.FormValue("pairs")) - if err := rc.UpdateLK(lk); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } } http.Redirect(w, r, "/admin/setup#lk-portfolio", http.StatusSeeOther) }) } + +// LKPortfolioClient — клиент DEPO для выбора в настройках: портфель, ФИО, торговый счёт и договор ЛК. +type LKPortfolioClient struct { + Port string `json:"port"` + Name string `json:"name"` + Account string `json:"account"` + LK string `json:"lk"` +} + +// lkContractByAccount — договор депо в ЛК по торговому счёту DEPO по шаблону: у Фонтвьель «ВТНКЦ00000816» → +// «ДД-816» (проверено 29.09.2026 на ДД-816 и ДД-1515). Префикс и шаблон — в настройках. +func lkContractByAccount(ps LKPortfolioSyncSettings, acc string) string { + acc = strings.TrimSpace(acc) + pre := ps.accountPrefix() + if !strings.HasPrefix(acc, pre) { + return "" + } + n := strings.TrimLeft(strings.TrimPrefix(acc, pre), "0") + if n == "" { + return "" + } + return strings.ReplaceAll(ps.contractTemplate(), "{n}", n) +} + +// lkPortfolioClients — поиск клиентов DEPO по ФИО или номеру портфеля (открытые торговые счета). +func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKPortfolioClient, error) { + q = strings.TrimSpace(q) + if len([]rune(q)) < 2 { + return nil, nil + } + fs := rc.Snapshot().Fancy + ps := rc.Snapshot().LK.PortfolioSync + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + return nil, err + } + defer db.Close() + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + rows, err := db.QueryContext(cctx, `SELECT TOP 20 ISNULL(p.PORT_NUM,''), ISNULL(f.NAME,''), c.NUMBER + FROM OD_CLIENTS c JOIN OD_FACES f ON f.ID=c.FACE LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID + WHERE c.NUMBER LIKE @pre AND c.STATE=0 AND c.D_CLOSE IS NULL AND (f.NAME LIKE @q OR p.PORT_NUM LIKE @q OR c.NUMBER LIKE @q) + ORDER BY f.NAME`, sql.Named("q", "%"+likeEscape(q)+"%"), sql.Named("pre", likeEscape(ps.accountPrefix())+"%")) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKPortfolioClient + for rows.Next() { + var c LKPortfolioClient + if rows.Scan(&c.Port, &c.Name, &c.Account) == nil && c.Port != "" { + c.LK = lkContractByAccount(ps, c.Account) + out = append(out, c) + } + } + return out, rows.Err() +} + +// lkPortfolioPushPair — залить портфель одного клиента сейчас (тест / «Залить сейчас»). +func lkPortfolioPushPair(ctx context.Context, rc *RuntimeConfig, pr lkPortfolioPair) { + lines, skipped, err := lkPortfolioLines(ctx, rc, pr.Depo, nil) + lkPortfolioState.Lock() + s := lkPortfolioState.m[pr.LK] + if s == nil { + s = &LKPortfolioSyncState{LK: pr.LK, Depo: pr.Depo} + lkPortfolioState.m[pr.LK] = s + } + s.Depo, s.CheckedAt, s.Skipped = pr.Depo, time.Now(), skipped + lkPortfolioState.Unlock() + msg := "" + if err != nil { + msg = err.Error() + } else if code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines); perr != nil { + msg = perr.Error() + } else if code != 200 || !strings.Contains(resp, `"success":true`) { + msg = fmt.Sprintf("HTTP %d: %s", code, resp) + } + lkPortfolioState.Lock() + s.Err = msg + if msg == "" { + raw, _ := json.Marshal(lines) + sum := sha256.Sum256(raw) + s.hash, s.PushedAt, s.Lines = hex.EncodeToString(sum[:]), time.Now(), len(lines) + } + lkPortfolioState.Unlock() + if msg != "" { + log.Printf("lk-portfolio: %s ← %s: заливка не прошла: %s", pr.LK, pr.Depo, msg) + } else { + log.Printf("lk-portfolio: %s ← %s: портфель ДЕПО залит в ЛК (%d бумаг, пропущено %d)", pr.LK, pr.Depo, len(lines), len(skipped)) + } +} diff --git a/internal/lkgateway/lkportfolio_test.go b/internal/lkgateway/lkportfolio_test.go index 44b2adf..225963d 100644 --- a/internal/lkgateway/lkportfolio_test.go +++ b/internal/lkgateway/lkportfolio_test.go @@ -16,3 +16,17 @@ func TestLKPaperTypeAndPairs(t *testing.T) { t.Fatalf("%+v", p) } } + +func TestLKContractByAccount(t *testing.T) { + def := LKPortfolioSyncSettings{} + if got := lkContractByAccount(def, "ВТНКЦ00000816"); got != "ДД-816" { + t.Fatalf("по умолчанию: %q", got) + } + if got := lkContractByAccount(def, "В00000816"); got != "" { + t.Fatalf("не торговый счёт: %q", got) + } + other := LKPortfolioSyncSettings{AccountPrefix: "TR-", ContractTemplate: "D/{n}/2024"} + if got := lkContractByAccount(other, "TR-0042"); got != "D/42/2024" { + t.Fatalf("другое именование: %q", got) + } +} diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index a33bf47..fbfc552 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -637,15 +637,71 @@ {{end}}
- Портфель ДЕПО → ЛК {{if .Settings.LK.PortfolioSync.Enabled}}включено{{else}}выключено{{end}} + Заполнение портфеля ДЕПО бумагами из DEPO {{if .Settings.LK.PortfolioSync.Enabled}}включено{{else}}выключено{{end}}

Каждые 10 секунд, в том же проходе, что и приём заявок, bj сверяет портфель клиента в DEPO и отправляет его в ЛК (/portfolio/import, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).

-
- - - - + + {{if .Settings.LK.PortfolioSync.Enabled}} + + {{else}}{{end}} +
+ + +
+
Именование у депозитария (счета, договоры, раздел, хранение) +
+ + + + + + +
+

Пусто — значение по умолчанию (серым). Пример: счёт ВТНКЦ00000816 и шаблон ДД-{n} → договор ДД-816.

+
+
+
Залить одному клиенту (для теста)
+
+ + + + +
+
+
+
+ {{range lkPortfolioStates}}
{{.LK}} ← {{.Depo}}: {{if .Err}}ошибка — {{.Err}}{{else if .PushedAt.IsZero}}сверено {{.CheckedAt.Format "15:04:05"}}, отправки ещё не было{{else}}отправлено {{.PushedAt.Format "02.01 15:04:05"}} ({{.Lines}} бумаг), сверено {{.CheckedAt.Format "15:04:05"}}{{end}}{{range .Skipped}}
пропущено: {{.}}{{end}}
{{end}}