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 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 { _ = db.QueryRowContext(ctx, "SELECT TOP 1 ISNULL(CFI,'') FROM OD_VALUES WHERE ISIN=@i ORDER BY ID", sql.Named("i", isin)).Scan(&cfi) if qerr == nil { _ = qe.QueryRow(ctx, `SELECT "RegNumber" FROM "Securities" WHERE "ISINCode"=$1 AND COALESCE("RegNumber",'')<>'' LIMIT 1`, isin).Scan(®) } if qerr == nil { lkSecInfoPut(isin, cfi, reg) } } if strings.TrimSpace(reg) == "" { why := "нет номера гос. регистрации в экспорте QUIK" if qerr != nil { why = "экспорт QUIK недоступен: " + qerr.Error() } 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: lkPortfolioSection, Storage: lkPortfolioStorage, 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"` } 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 } for _, pr := range st.pairs() { 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 } 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) { mux.HandleFunc("/admin/setup/lk-portfolio", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } if r.FormValue("action") == "sync" { 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) }) }