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" "sort" "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 db, err := fancyOpen(fs, fs.DepoDB()) if err != nil { return nil, nil, fmt.Errorf("DEPO: %w", err) } defer db.Close() pos, err := depoClientPositionsDB(ctx, db, depoPort) if err != nil { return nil, nil, fmt.Errorf("DEPO: %w", err) } lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, pos, onlyISIN) return lines, skipped, nil } // lkPortfolioLinesFrom — строки для ЛК из уже прочитанных остатков; db — открытая база DEPO (вид бумаги // и номер гос. регистрации, кэш на час). func lkPortfolioLinesFrom(ctx context.Context, rc *RuntimeConfig, db *sql.DB, pos []DepoPosition, onlyISIN map[string]bool) (lines []LKPortfolioLine, skipped []string) { ps := rc.Snapshot().LK.PortfolioSync 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 } // lkPortfolioByPort — остатки из общего запроса (depoAllPositions) по портфелям пар, как в Dp01: портфель // совпадает или начинается с него и пробела («БР-1001» → «БР-1001 Гравс Геннадий»). Порядок строк — по // бумаге, счёту, ISIN: от него зависит отпечаток портфеля, ЛК повторно не получает тот же портфель. func lkPortfolioByPort(all map[string][]DepoPosition, prs []lkPortfolioPair) map[string][]DepoPosition { want := map[string]bool{} for _, pr := range prs { want[pr.Depo] = true } out := map[string][]DepoPosition{} for port, pos := range all { for i := 0; i <= len(port); i++ { if i == len(port) || port[i] == ' ' { if k := port[:i]; want[k] { out[k] = append(out[k], pos...) } } } } for _, pos := range out { sort.SliceStable(pos, func(i, j int) bool { a, b := pos[i], pos[j] if a.SecurityName != b.SecurityName { return a.SecurityName < b.SecurityName } if a.AccountNum != b.AccountNum { return a.AccountNum < b.AccountNum } return a.ISIN < b.ISIN }) } return out } // 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 Nothing string // отправлять нечего (нет остатков или все бумаги без номера гос. регистрации) — не ошибка hash string manual bool // ручная заливка («Залить сейчас»), не постоянное заполнение } var lkPortfolioState = struct { sync.Mutex m map[string]*LKPortfolioSyncState }{m: map[string]*LKPortfolioSyncState{}} // lkPortfolioRun — проходы по очереди: фоновый и «Заполнить сейчас» не отправляют один портфель дважды // (23.10.2026 при включении «для всех» 481 договор ушёл дважды). var lkPortfolioRun sync.Mutex var lkPortfolioTestOnce sync.Once // lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force — кроме // отправленных в последнюю минуту). Одно соединение с DEPO на проход; «для всех клиентов» — остатки одним // запросом (23.10.2026: по два новых соединения и запросу на каждого из ~600 клиентов — ~50 входов в DEPO в секунду). func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) { st := rc.Snapshot().LK.PortfolioSync if !st.Enabled { return } if lkProdOnTestNSD(rc) { // тестовая DEPO не пишет портфели клиентов в боевой ЛК (lkconnector.go) lkPortfolioTestOnce.Do(func() { log.Printf("lk-portfolio: не заполняется — боевой ЛК на сервере с тестовым контуром НРД (%s)", rc.Snapshot().NSD.Profile) }) return } lkPortfolioRun.Lock() defer lkPortfolioRun.Unlock() fs := rc.Snapshot().Fancy db, err := fancyOpen(fs, fs.DepoDB()) if err != nil { log.Printf("lk-portfolio: DEPO: %v", err) return } defer db.Close() prs := st.pairs() var byPort map[string][]DepoPosition if st.AllClients { all, err := lkPortfolioAllPairs(ctx, rc, db) if err != nil { log.Printf("lk-portfolio: список клиентов (все): %v", err) return } pos, err := depoAllPositions(ctx, db) if err != nil { log.Printf("lk-portfolio: остатки DEPO (все клиенты): %v", err) return } prs, byPort = all, lkPortfolioByPort(pos, all) } for _, pr := range prs { var lines []LKPortfolioLine var skipped []string var err error if byPort != nil { lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, byPort[pr.Depo], nil) } else if pos, perr := depoClientPositionsDB(ctx, db, pr.Depo); perr != nil { err = fmt.Errorf("DEPO: %w", perr) } else { lines, skipped = lkPortfolioLinesFrom(ctx, rc, db, pos, 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.manual = pr.Depo, time.Now(), false 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 // отпечаток ставится только после принятой отправки или «нечего отправлять» if same { s.Err = "" // DEPO снова читается, портфель тот же — прежняя ошибка чтения снята } recent := !s.PushedAt.IsZero() && time.Since(s.PushedAt) < time.Minute lkPortfolioState.Unlock() if same && (!force || recent) { continue } if len(lines) == 0 { // ЛК отвечает 500 на пустой импорт — не шлём lkPortfolioState.Lock() s.Skipped, s.Err, s.Nothing, s.hash = skipped, "", lkPortfolioNothing(skipped), h lkPortfolioState.Unlock() continue } code, resp, perr := lkPortfolioPush(ctx, rc, pr.LK, lines) lkPortfolioState.Lock() prev := s.Err s.Skipped, s.Nothing = 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() switch { case perr != nil: // ЛК не отвечает — остальным договорам этого прохода тоже не уйти log.Printf("lk-portfolio: %s ← %s: отправка не прошла, проход прерван: %s", pr.LK, pr.Depo, msg) return case msg != "" && msg != prev: // та же ошибка в следующих проходах — без повтора в журнале log.Printf("lk-portfolio: %s ← %s: отправка не прошла: %s", pr.LK, pr.Depo, msg) case msg == "": log.Printf("lk-portfolio: %s ← %s: портфель ДЕПО обновлён в ЛК (%d бумаг, пропущено %d)", pr.LK, pr.Depo, len(lines), len(skipped)) } } } // lkPortfolioAllEvery — как часто сверять портфели «для всех клиентов» (по списку — каждые 10 секунд). const lkPortfolioAllEvery = 5 * time.Minute // lkPortfolioLoop — заполнение портфеля ДЕПО в ЛК, отдельно от опроса заявок ЛК (23.10.2026: проход по всем // клиентам задерживал приём заявок). По списку — каждые 10 секунд, для всех клиентов — раз в 5 минут. func (s *Server) lkPortfolioLoop(ctx context.Context) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() var lastAll time.Time for { select { case <-ctx.Done(): return case <-ticker.C: healthBeat("lk-portfolio") ps := s.rc.Snapshot().LK.PortfolioSync if !ps.Enabled { continue } if _, ok := lkActiveEndpoint(s.rc); !ok || !moduleAllowed(s.rc, "esia-finance") { continue } if ps.AllClients { if time.Since(lastAll) < lkPortfolioAllEvery { continue } lastAll = time.Now() } pctx, cancel := context.WithTimeout(ctx, 4*time.Minute) lkPortfolioSyncTick(pctx, s.rc, false) cancel() } } } // lkPortfolioStates — состояние для страницы настроек: последняя ручная заливка сверху, затем клиенты // постоянного заполнения. func lkPortfolioStates() []LKPortfolioSyncState { lkPortfolioState.Lock() defer lkPortfolioState.Unlock() var out []LKPortfolioSyncState for _, s := range lkPortfolioState.m { out = append(out, *s) } sort.Slice(out, func(i, j int) bool { if out[i].manual != out[j].manual { return out[i].manual } return out[i].LK < out[j].LK }) return out } // lkPortfolioRecentN — сколько последних отправок видно в настройках без раскрытия списка. const lkPortfolioRecentN = 5 // LKPortfolioSummary — итог заполнения для настроек (23.10.2026: «для всех» — сотни договоров, список // целиком на странице мешает): счётчики, последние отправки; ошибки и все договоры — под раскрытием. type LKPortfolioSummary struct { Total, Sent, Nothing, Errors, Skipped int LastPush, LastCheck time.Time Manual *LKPortfolioSyncState // последняя ручная заливка («Залить сейчас») Recent, Failed, All []LKPortfolioSyncState } func lkPortfolioSummary() LKPortfolioSummary { var sm LKPortfolioSummary for _, s := range lkPortfolioStates() { if s.manual { m := s sm.Manual = &m continue } sm.Total++ sm.Skipped += len(s.Skipped) switch { case s.Err != "": sm.Errors++ sm.Failed = append(sm.Failed, s) case s.Nothing != "": sm.Nothing++ case !s.PushedAt.IsZero(): sm.Sent++ } if s.PushedAt.After(sm.LastPush) { sm.LastPush = s.PushedAt } if s.CheckedAt.After(sm.LastCheck) { sm.LastCheck = s.CheckedAt } sm.All = append(sm.All, s) if !s.PushedAt.IsZero() { sm.Recent = append(sm.Recent, s) } } sort.SliceStable(sm.Recent, func(i, j int) bool { return sm.Recent[i].PushedAt.After(sm.Recent[j].PushedAt) }) if len(sm.Recent) > lkPortfolioRecentN { sm.Recent = sm.Recent[:lkPortfolioRecentN] } return sm } // lkPortfolioInSync — договор в постоянном заполнении (по списку или «для всех»). func lkPortfolioInSync(rc *RuntimeConfig, lk string) bool { ps := rc.Snapshot().LK.PortfolioSync if ps.Enabled && ps.AllClients { return true } for _, p := range ps.pairs() { if p.LK == lk { return true } } return false } // 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, db *sql.DB) ([]lkPortfolioPair, error) { ps := rc.Snapshot().LK.PortfolioSync 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 if !lkPortfolioInSync(rc, pr.LK) { s.manual = true for k, o := range lkPortfolioState.m { // от прежних ручных заливок — только последняя if o.manual && k != pr.LK { delete(lkPortfolioState.m, k) } } } lkPortfolioState.Unlock() msg, nothing := "", "" if err != nil { msg = err.Error() } else if len(lines) == 0 { nothing = lkPortfolioNothing(skipped) msg = nothing } 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, s.Nothing = msg, nothing if nothing != "" { s.Err = "" } 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) }