package lkgateway // Синхронизация депозитарного портфеля клиента из DEPO в ЛК ЕСИА Финанс (29.09.2026): метод back_office // POST /portfolio/import?import_type=depo_portfolio. Форма заявки M2M в ЛК берёт бумаги клиента из этого // портфеля (по счёту депо и разделу). Источники полей: // - остатки, счёт депо, ISIN, наименование — DEPO (OD_RESTS / OD_VALUES); // - вид бумаги (paper_type) — код CFI в DEPO: ED — депозитарная расписка, E — акция, D — облигация, C — фонд; // - номер гос. регистрации — экспорт QUIK (Securities.RegNumber); в DEPO его нет, а ЛК без него не принимает; // - раздел счёта — «Основной» (решение 29.09.2026: в DEPO и в ЛК разделов нет, ЛК требует непустой), // место хранения — «НРД». // Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт) — они в отчёте. import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "strings" "sync" "time" ) const ( lkPortfolioSection = "Основной" lkPortfolioStorage = "НРД" ) // LKPortfolioLine — строка портфеля ДЕПО для ЛК. type LKPortfolioLine struct { ISIN string `json:"isin"` Name string `json:"name"` RegNumber string `json:"registration_number"` Count float64 `json:"count"` DepoAccount string `json:"depo_account"` Section string `json:"depo_account_section"` Storage string `json:"storage"` PaperType string `json:"paper_type"` } // lkPaperType — вид бумаги по коду CFI (ISO 10962). func lkPaperType(cfi, name string) string { cfi = strings.ToUpper(strings.TrimSpace(cfi)) switch { case strings.HasPrefix(cfi, "ED"): return "depositary_receipt" case strings.HasPrefix(cfi, "E"): return "share" case strings.HasPrefix(cfi, "D"): return "bond" case strings.HasPrefix(cfi, "C"): return "fund" } low := strings.ToLower(name) switch { case strings.Contains(low, "облиг") || strings.Contains(low, "офз") || strings.Contains(low, " обл"): return "bond" case strings.Contains(low, "пиф") || strings.Contains(low, "пай") || strings.Contains(low, "фонд"): return "fund" case strings.Contains(low, "расписк"): return "depositary_receipt" } return "share" } // lkPortfolioLines — портфель клиента (портфель DEPO, напр. «БР-859») для ЛК; onlyISIN — только эти бумаги // (пусто — все). 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) } db, err := fancyOpen(fs, fs.DepoDB()) if err != nil { return nil, nil, fmt.Errorf("DEPO: %w", err) } defer db.Close() qe, qerr := nprDB(rc) // экспорт QUIK (только чтение) — номер гос. регистрации for _, p := range pos { isin := strings.ToUpper(strings.TrimSpace(p.ISIN)) if isin == "" || (len(onlyISIN) > 0 && !onlyISIN[isin]) || p.Quantity <= 0 { continue } cfi, reg, cached := lkSecInfoCached(isin) if !cached { // Номер гос. регистрации — из DEPO (OD_SHARES.NUM_REG — выпуск бумаги, OD_SHARES.ID = OD_VALUES.ID), // запасной — экспорт QUIK. Проверено на DEPO 28: у Сбербанка 10301481B, у ОФЗ 26222RMFS. _ = db.QueryRowContext(ctx, `SELECT TOP 1 ISNULL(v.CFI,''), ISNULL(s.NUM_REG,'') FROM OD_VALUES v LEFT JOIN OD_SHARES s ON s.ID=v.ID WHERE v.ISIN=@i ORDER BY CASE WHEN ISNULL(s.NUM_REG,'')<>'' THEN 0 ELSE 1 END, v.ID`, sql.Named("i", isin)).Scan(&cfi, ®) if strings.TrimSpace(reg) == "" && qerr == nil { _ = qe.QueryRow(ctx, `SELECT "RegNumber" FROM "Securities" WHERE "ISINCode"=$1 AND COALESCE("RegNumber",'')<>'' LIMIT 1`, isin).Scan(®) } if strings.TrimSpace(reg) != "" || qerr == nil { lkSecInfoPut(isin, cfi, reg) } } if strings.TrimSpace(reg) == "" { why := "нет номера гос. регистрации ни в DEPO, ни в экспорте QUIK (иностранные бумаги, КСУ)" if qerr != nil { why = "нет номера гос. регистрации в DEPO (экспорт QUIK не подключён)" } skipped = append(skipped, fmt.Sprintf("%s %s (%s): %s", isin, p.SecurityName, p.AccountNum, why)) continue } lines = append(lines, LKPortfolioLine{ ISIN: isin, Name: strings.TrimSpace(p.SecurityName), RegNumber: strings.TrimSpace(reg), Count: p.Quantity, DepoAccount: p.AccountNum, Section: ps.section(), Storage: ps.storage(), PaperType: lkPaperType(cfi, p.SecurityName), }) } return lines, skipped, nil } // lkPortfolioPush — отправить портфель ДЕПО договора ЛК (номер договора депо в ЛК, напр. «ДД-816»). func lkPortfolioPush(ctx context.Context, rc *RuntimeConfig, lkContract string, lines []LKPortfolioLine) (int, string, error) { ep, ok := lkActiveEndpoint(rc) if !ok { return 0, "", fmt.Errorf("ЛК не настроен") } body, _ := json.Marshal(map[string]any{"data": map[string]any{lkContract: lines}}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, lkAPIBase(ep.BaseURL)+"/portfolio/import?import_type=depo_portfolio", strings.NewReader(string(body))) if err != nil { return 0, "", err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") req.SetBasicAuth(ep.Login, ep.Password) resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req) if err != nil { return 0, "", err } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) return resp.StatusCode, strings.TrimSpace(string(b)), nil } // Кэш вида бумаги (CFI) и номера гос. регистрации на час: портфель сверяется каждые 10 секунд. var lkSecInfo = struct { sync.Mutex m map[string]lkSecInfoEntry }{m: map[string]lkSecInfoEntry{}} type lkSecInfoEntry struct { cfi, reg string at time.Time } func lkSecInfoCached(isin string) (cfi, reg string, ok bool) { lkSecInfo.Lock() defer lkSecInfo.Unlock() e, ok := lkSecInfo.m[isin] if !ok || time.Since(e.at) > time.Hour { return "", "", false } return e.cfi, e.reg, true } func lkSecInfoPut(isin, cfi, reg string) { lkSecInfo.Lock() lkSecInfo.m[isin] = lkSecInfoEntry{cfi: cfi, reg: reg, at: time.Now()} lkSecInfo.Unlock() } // LKPortfolioSyncSettings — какие договоры ЛК синхронизировать и с какими портфелями DEPO. 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"` // место хранения: «НРД» // AllClients — заполнять портфель ВСЕМ клиентам: все открытые торговые счета DEPO // (по префиксу), договор ЛК выводится из счёта по шаблону. Иначе — только Pairs. AllClients bool `json:"all_clients,omitempty"` } func (s LKPortfolioSyncSettings) accountPrefix() string { return pickStr(strings.TrimSpace(s.AccountPrefix), depoAccPrefix()) } 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 } func (s LKPortfolioSyncSettings) pairs() []lkPortfolioPair { var out []lkPortfolioPair for _, p := range strings.Split(s.Pairs, ";") { a, b, ok := strings.Cut(p, "=") if a, b = strings.TrimSpace(a), strings.TrimSpace(b); ok && a != "" && b != "" { out = append(out, lkPortfolioPair{LK: a, Depo: b}) } } return out } // LKPortfolioSyncState — итог по договору (для настроек). type LKPortfolioSyncState struct { LK, Depo string CheckedAt time.Time // последняя сверка с DEPO PushedAt time.Time // последняя отправка в ЛК Lines int Skipped []string Err string hash string } var lkPortfolioState = struct { sync.Mutex m map[string]*LKPortfolioSyncState }{m: map[string]*LKPortfolioSyncState{}} // lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force). func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) { st := rc.Snapshot().LK.PortfolioSync if !st.Enabled { return } prs := st.pairs() if st.AllClients { all, err := lkPortfolioAllPairs(ctx, rc) if err != nil { log.Printf("lk-portfolio: список клиентов (все): %v", err) return } prs = all } for _, pr := range prs { 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 = pr.Depo, time.Now() if err != nil { if s.Err != err.Error() { log.Printf("lk-portfolio: %s ← %s: %v", pr.LK, pr.Depo, err) } s.Err = err.Error() lkPortfolioState.Unlock() continue } raw, _ := json.Marshal(lines) sum := sha256.Sum256(raw) h := hex.EncodeToString(sum[:]) same := h == s.hash lkPortfolioState.Unlock() if same && !force { continue } if len(lines) == 0 { // ЛК отвечает 500 на пустой импорт — не шлём lkPortfolioState.Lock() s.Skipped, s.Err, s.hash = skipped, lkPortfolioNothing(skipped), h lkPortfolioState.Unlock() continue } code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines) lkPortfolioState.Lock() s.Skipped = skipped switch { case perr != nil: s.Err = perr.Error() case code != 200 || !strings.Contains(resp, `"success":true`): s.Err = fmt.Sprintf("HTTP %d: %s", code, resp) default: s.Err, s.hash, s.PushedAt, s.Lines = "", h, time.Now(), len(lines) } msg := s.Err 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)) } } } // lkPortfolioStates — состояние синхронизации для страницы настроек. func lkPortfolioStates() []LKPortfolioSyncState { lkPortfolioState.Lock() defer lkPortfolioState.Unlock() var out []LKPortfolioSyncState for _, s := range lkPortfolioState.m { out = append(out, *s) } return out } // 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 } 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 != "" || lk.PortfolioSync.AllClients case "on_all": lk.PortfolioSync.AllClients, lk.PortfolioSync.Enabled = true, true case "off": lk.PortfolioSync.Enabled, lk.PortfolioSync.AllClients = false, 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) n, skipped, perr := lkPortfolioPushPair(ctx, rc, pr) cancel() msg := fmt.Sprintf("Портфель залит в ЛК: договор %s ← портфель DEPO %s, бумаг %d", lkc, port, n) if skipped > 0 { msg += fmt.Sprintf(" (пропущено %d — без номера гос. регистрации)", skipped) } if perr != "" { msg = "Портфель не залит в ЛК (" + lkc + " ← " + port + "): " + perr } if r.FormValue("action") == "client_add" { msg += ". Клиент добавлен в постоянное заполнение." } lkPortfolioDone(w, r, msg) return } msg := "Настройки заполнения портфеля сохранены" switch a := r.FormValue("action"); a { case "sync", "on", "on_all": // включили или «Заполнить сейчас» — сразу отправить ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) lkPortfolioSyncTick(ctx, rc, true) cancel() ok, bad := 0, 0 for _, st := range lkPortfolioStates() { if st.Err != "" { bad++ } else if !st.PushedAt.IsZero() { ok++ } } msg = map[string]string{"on": "Заполнение по списку включено", "on_all": "Заполнение для всех клиентов включено", "sync": "Портфели сверены"}[a] + fmt.Sprintf(": залито договоров %d, с ошибкой %d — подробности под карточкой", ok, bad) case "off": msg = "Заполнение портфеля выключено" } lkPortfolioDone(w, r, msg) }) } // 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)) == 1 { return []LKPortfolioClient{}, 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 50 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() 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() } // lkPortfolioAllPairs — все открытые торговые счета DEPO с портфелем (режим «для всех // клиентов»): фильтр по префиксу счёта, договор ЛК выводится из счёта по шаблону, // портфель — PORT_NUM. Только MSSQL-база DEPO (как и поиск одного клиента). func lkPortfolioAllPairs(ctx context.Context, rc *RuntimeConfig) ([]lkPortfolioPair, error) { 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, 30*time.Second) defer cancel() rows, err := db.QueryContext(cctx, `SELECT ISNULL(p.PORT_NUM,''), c.NUMBER FROM OD_CLIENTS c 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 p.PORT_NUM IS NOT NULL`, sql.Named("pre", likeEscape(ps.accountPrefix())+"%")) if err != nil { return nil, err } defer rows.Close() var out []lkPortfolioPair seen := map[string]bool{} for rows.Next() { var port, acc string if rows.Scan(&port, &acc) != nil || strings.TrimSpace(port) == "" { continue } lk := lkContractByAccount(ps, acc) if lk == "" || seen[lk] { continue } seen[lk] = true out = append(out, lkPortfolioPair{LK: lk, Depo: strings.TrimSpace(port)}) } return out, rows.Err() } // lkPortfolioPushPair — залить портфель одного клиента сейчас (тест / «Залить сейчас»): сколько бумаг // ушло, сколько пропущено, ошибка (пусто — залито). func lkPortfolioPushPair(ctx context.Context, rc *RuntimeConfig, pr lkPortfolioPair) (int, int, string) { 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 len(lines) == 0 { msg = lkPortfolioNothing(skipped) } 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)) } return len(lines), len(skipped), msg } // lkPortfolioNothing — почему портфель не отправлен: отправлять нечего. func lkPortfolioNothing(skipped []string) string { if len(skipped) == 0 { return "отправлять нечего: в DEPO у клиента нет бумаг на остатке" } return fmt.Sprintf("отправлять нечего: у всех бумаг (%d) нет номера гос. регистрации, без него ЛК не принимает портфель", len(skipped)) } // lkPortfolioDone — вернуться к карточке «Заполнение портфеля» в том же разделе настроек с сообщением. func lkPortfolioDone(w http.ResponseWriter, r *http.Request, msg string) { r.Form.Set("section", "lk-portfolio") setupFlash(w, r, msg) }