diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 3e957b0..16b7624 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -55,6 +55,7 @@ var templateFuncs = template.FuncMap{ "realTest": realTestGet, "realNSDAs": realNSDAsText, "quikRecipientsInfo": quikRecipientsInfo, + "lkPortfolioStates": lkPortfolioStates, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], "devLicensed": authTemplateFuncs["devLicensed"], diff --git a/internal/lkgateway/evparams_live_test.go b/internal/lkgateway/evparams_live_test.go new file mode 100644 index 0000000..230c2b4 --- /dev/null +++ b/internal/lkgateway/evparams_live_test.go @@ -0,0 +1,45 @@ +//go:build live + +package lkgateway + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" +) + +// Дописать PARAMETERS событиям перевода, где оно пустое. EV_GUID, EV_TYPES (через запятую не нужно — два типа), EV_PARAMS, EV_GO=1. +func TestEventParamsLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + db, err := depoEventsDB(rc) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + g, p := os.Getenv("EV_GUID"), os.Getenv("EV_PARAMS") + if os.Getenv("EV_GO") == "1" { + res, err := db.ExecContext(ctx, `UPDATE dbo.FTV_M2M_EVENTS SET PARAMETERS=@p + WHERE GUID=@g AND EVENT_TYPE IN ('BJ_ORDER_ACCEPTED','BJ_EXECUTED') AND (PARAMETERS IS NULL OR PARAMETERS='')`, sql.Named("p", p), sql.Named("g", g)) + if err != nil { + t.Fatal(err) + } + n, _ := res.RowsAffected() + fmt.Println("обновлено строк:", n) + } + rows, err := db.QueryContext(ctx, `SELECT EVENT_TYPE, ISNULL(REFERENCE,''), ISNULL(STATE,''), ISNULL(PARAMETERS,''), EVENT_DATE FROM dbo.FTV_M2M_EVENTS WHERE GUID=@g ORDER BY EVENT_DATE`, sql.Named("g", g)) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var ty, ref, st, pr string + var at interface{} + _ = rows.Scan(&ty, &ref, &st, &pr, &at) + fmt.Printf("%-18s %-18s %-8s %-24q %v\n", ty, ref, st, pr, at) + } +} diff --git a/internal/lkgateway/lkbackup_live_test.go b/internal/lkgateway/lkbackup_live_test.go new file mode 100644 index 0000000..8e87380 --- /dev/null +++ b/internal/lkgateway/lkbackup_live_test.go @@ -0,0 +1,39 @@ +//go:build live + +package lkgateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" +) + +// Только чтение: снимок данных клиента в ЛК ЕСИА Финанс. LK_PATHS=/contracts/1557,/contracts/1557/depo_accounts,/users/891 LK_OUT=файл +func TestLKBackupLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + ep, ok := lkActiveEndpoint(rc) + if !ok { + t.Fatal("ЛК не настроен") + } + out := map[string]any{"at": time.Now().Format(time.RFC3339), "env": rc.Snapshot().LK.ActiveEnv} + for _, p := range strings.Split(os.Getenv("LK_PATHS"), ",") { + code, body, err := lkGet(context.Background(), ep, p) + var v any + if json.Unmarshal(body, &v) != nil { + v = string(body) + } + out[p] = map[string]any{"http": code, "err": fmt.Sprint(err), "body": v} + fmt.Println(p, "→ HTTP", code, err, len(body), "байт") + } + raw, _ := json.MarshalIndent(out, "", " ") + if err := os.WriteFile(os.Getenv("LK_OUT"), raw, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/lkgateway/lkconnector.go b/internal/lkgateway/lkconnector.go index c5ae5f2..118674e 100644 --- a/internal/lkgateway/lkconnector.go +++ b/internal/lkgateway/lkconnector.go @@ -294,6 +294,10 @@ func (s *Server) pollLKClaims(ctx context.Context) { log.Printf("lk-gateway: поллер ЛК: связь с ЛК восстановлена") lastErr = "" } + // тот же проход: портфели ДЕПО из DEPO в ЛК (только изменившиеся, lkportfolio.go) + pctx, pcancel := context.WithTimeout(ctx, time.Minute) + lkPortfolioSyncTick(pctx, s.rc, false) + pcancel() if res.Fetched > 0 || len(res.Errors) > 0 { log.Printf("lk-gateway: поллер ЛК: получено %d, отправлено %d, отклонено %d, ошибок %d (имитация НРД: %v)", res.Fetched, res.Sent, res.Rejected, len(res.Errors), res.Emulated) diff --git a/internal/lkgateway/lkportfolio.go b/internal/lkgateway/lkportfolio.go new file mode 100644 index 0000000..279172d --- /dev/null +++ b/internal/lkgateway/lkportfolio.go @@ -0,0 +1,286 @@ +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) + }) +} diff --git a/internal/lkgateway/lkportfolio_live_test.go b/internal/lkgateway/lkportfolio_live_test.go new file mode 100644 index 0000000..bc1b57b --- /dev/null +++ b/internal/lkgateway/lkportfolio_live_test.go @@ -0,0 +1,41 @@ +//go:build live + +package lkgateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" +) + +// Портфель ДЕПО в ЛК ЕСИА Финанс. PF_CONTRACT=ДД-816 PF_PORT=БР-859 PF_ISIN=RU0009029540[,…] [PF_GO=1 — отправить] +func TestLKPortfolioLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + only := map[string]bool{} + for _, i := range strings.Split(os.Getenv("PF_ISIN"), ",") { + if i = strings.ToUpper(strings.TrimSpace(i)); i != "" { + only[i] = true + } + } + lines, skipped, err := lkPortfolioLines(context.Background(), rc, os.Getenv("PF_PORT"), only) + if err != nil { + t.Fatal(err) + } + body, _ := json.MarshalIndent(map[string]any{"data": map[string]any{os.Getenv("PF_CONTRACT"): lines}}, "", " ") + fmt.Println("запрос: POST /portfolio/import?import_type=depo_portfolio") + fmt.Println(string(body)) + for _, s := range skipped { + fmt.Println("пропущено:", s) + } + if os.Getenv("PF_GO") != "1" { + return + } + code, resp, err := lkPortfolioPush(context.Background(), rc, os.Getenv("PF_CONTRACT"), lines) + fmt.Println("ответ HTTP", code, resp, err) +} diff --git a/internal/lkgateway/lkportfolio_test.go b/internal/lkgateway/lkportfolio_test.go new file mode 100644 index 0000000..44b2adf --- /dev/null +++ b/internal/lkgateway/lkportfolio_test.go @@ -0,0 +1,18 @@ +package lkgateway + +import "testing" + +func TestLKPaperTypeAndPairs(t *testing.T) { + for cfi, want := range map[string]string{"ESVXFR": "share", "EDSXFR": "depositary_receipt", "DBFXFR": "bond", "CEOGMR": "fund"} { + if got := lkPaperType(cfi, ""); got != want { + t.Fatalf("%s: %s", cfi, got) + } + } + if lkPaperType("", "ОФЗ 26238 ПД обл") != "bond" || lkPaperType("", "БПИФ «Ликвидность» фонд") != "fund" { + t.Fatal("по названию") + } + p := LKPortfolioSyncSettings{Pairs: "ДД-816=БР-859; мусор ;ДД-1 = БР-1"}.pairs() + if len(p) != 2 || p[0].LK != "ДД-816" || p[0].Depo != "БР-859" || p[1].Depo != "БР-1" { + t.Fatalf("%+v", p) + } +} diff --git a/internal/lkgateway/odvals_live_test.go b/internal/lkgateway/odvals_live_test.go new file mode 100644 index 0000000..bc76220 --- /dev/null +++ b/internal/lkgateway/odvals_live_test.go @@ -0,0 +1,52 @@ +//go:build live + +package lkgateway + +import ( + "context" + "database/sql" + "fmt" + "testing" +) + +// Только чтение: строка остатка SBER у ВТНКЦ00000816 целиком + таблицы с рег. номером. +func TestODValuesLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + fs := rc.Snapshot().Fancy + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + dump := func(q string, args ...any) { + rows, err := db.QueryContext(ctx, q, args...) + if err != nil { + fmt.Println("ошибка:", err) + return + } + cols, _ := rows.Columns() + for rows.Next() { + vals := make([]sql.NullString, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + _ = rows.Scan(ptrs...) + for i, c := range cols { + if vals[i].Valid && vals[i].String != "" && vals[i].String != "0" { + fmt.Printf("%s=%s | ", c, vals[i].String) + } + } + fmt.Println() + } + rows.Close() + } + fmt.Println("== OD_ACC_PLANS план 4") + dump("SELECT * FROM OD_ACC_PLANS WHERE ID=4") + fmt.Println("== OD_PLAN_DIRS план 4") + dump("SELECT * FROM OD_PLAN_DIRS WHERE PLAN_ID=4") +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 6235370..b86ed1d 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -277,6 +277,8 @@ type LKSettings struct { // RealNSDTest — тест «боевой ЛК → тестовый контур НРД»: заявки указанных клиентов проходят всё // по-настоящему (DEPO, QUIK МНП, статусы в ЛК), а в НРД уходят с данными тест-инвестора (realtest.go). RealNSDTest RealNSDTestSettings `json:"real_nsd_test,omitempty"` + // PortfolioSync — портфель ДЕПО из DEPO в ЛК ЕСИА Финанс (lkportfolio.go): каждый проход опроса ЛК. + PortfolioSync LKPortfolioSyncSettings `json:"portfolio_sync,omitempty"` // Интеграция с ЛК (потребление back_office API платформы): два окружения. ActiveEnv string `json:"lk_active_env"` // "test" | "prod" diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index 9a1d92a..a33bf47 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -636,6 +636,18 @@ {{if devOn}} {{end}} +
Каждые 10 секунд, в том же проходе, что и приём заявок, bj сверяет портфель клиента в DEPO и отправляет его в ЛК (/portfolio/import, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).
Заявки клиентов из списка проходят у нас всё по-настоящему: сверка и поручение в DEPO, блокировка и списание в QUIK МНП (боевая запись), статусы в ЛК. В НРД (тестовый контур) запрос уходит с данными тест-инвестора {{realNSDAs}}; GUID, бумаги и счета — настоящие, ответы НРД применяются к заявке клиента. «Исполнена» в ЛК — по отчёту НРД об исполнении (MS010). Остальные заявки ЛК — как раньше. Включайте до того, как клиент подпишет заявку.