e47e6cfbdc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
287 lines
10 KiB
Go
287 lines
10 KiB
Go
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)
|
|
})
|
|
}
|