diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 025f71e..84f840b 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -66,7 +66,7 @@ var templateFuncs = template.FuncMap{ "realNSDAs": realNSDAsText, "quikRecipientsInfo": quikRecipientsInfo, "quikNeedsTrust": quikNeedsTrust, - "lkPortfolioStates": lkPortfolioStates, + "lkPortfolioSummary": lkPortfolioSummary, "backupStatus": backupStatus, "backupWhen": backupWhen, "smbWin": smbWin, diff --git a/internal/lkgateway/depodb.go b/internal/lkgateway/depodb.go index 18489ff..cf8d296 100644 --- a/internal/lkgateway/depodb.go +++ b/internal/lkgateway/depodb.go @@ -2,6 +2,7 @@ package lkgateway import ( "context" + "database/sql" "fmt" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql" "strings" @@ -65,6 +66,11 @@ func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract str return nil, err } defer conn.Close() + return depoClientPositionsDB(ctx, conn, contract) +} + +// depoClientPositionsDB — то же на открытом соединении (заполнение портфеля в ЛК: одно соединение на проход). +func depoClientPositionsDB(ctx context.Context, conn *sql.DB, contract string) ([]DepoPosition, error) { cctx, cancel := context.WithTimeout(ctx, 25*time.Second) defer cancel() @@ -73,6 +79,31 @@ func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract str if err != nil { return nil, err } + return depoScanPositions(rows) +} + +// depoAllPositions — остатки всех клиентов одним запросом (заполнение портфеля «для всех клиентов»), +// по портфелю (PORT_NUM без пробелов по краям). +func depoAllPositions(ctx context.Context, conn *sql.DB) (map[string][]DepoPosition, error) { + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + rows, err := conn.QueryContext(cctx, fsql.Dp03) + if err != nil { + return nil, err + } + pos, err := depoScanPositions(rows) + if err != nil { + return nil, err + } + out := map[string][]DepoPosition{} + for _, p := range pos { + k := strings.TrimSpace(p.Contract) + out[k] = append(out[k], p) + } + return out, nil +} + +func depoScanPositions(rows *sql.Rows) ([]DepoPosition, error) { defer rows.Close() var out []DepoPosition for rows.Next() { diff --git a/internal/lkgateway/fsql/depodb.go b/internal/lkgateway/fsql/depodb.go index 27c3d3a..c29b81c 100644 --- a/internal/lkgateway/fsql/depodb.go +++ b/internal/lkgateway/fsql/depodb.go @@ -17,3 +17,13 @@ var Dp02 = `SELECT TOP (%d) p.PORT_NUM, COUNT(DISTINCT rst.VALUE_ID) JOIN OD_VALUES v ON v.ID = rst.VALUE_ID AND v.V_TYPE = 2 WHERE CAST(rst.K AS float) <> 0 AND (@p1 = '' OR p.PORT_NUM LIKE @p2) GROUP BY p.PORT_NUM ORDER BY p.PORT_NUM` + +// Dp03 — Dp01 для всех портфелей сразу (заполнение портфеля ДЕПО в ЛК «для всех клиентов»). +var Dp03 = `SELECT ISNULL(p.PORT_NUM,''), ISNULL(c.NUMBER,''), ISNULL(v.ISIN,''), ISNULL(v.NAME,''), + CAST(rst.K AS float), ISNULL(rst.BLOCK_REASON,''), ISNULL(rst.BAL_ACC,0) + FROM OD_RESTS rst + JOIN OD_CLIENTS c ON c.ID = rst.REG_1 + JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID + JOIN OD_VALUES v ON v.ID = rst.VALUE_ID AND v.V_TYPE = 2 + WHERE CAST(rst.K AS float) <> 0 + ORDER BY p.PORT_NUM, v.NAME, c.NUMBER, v.ISIN` diff --git a/internal/lkgateway/lkconnector.go b/internal/lkgateway/lkconnector.go index 551377b..89f4fa6 100644 --- a/internal/lkgateway/lkconnector.go +++ b/internal/lkgateway/lkconnector.go @@ -295,10 +295,7 @@ 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() + // портфели ДЕПО из DEPO в ЛК — отдельный цикл lkPortfolioLoop (lkportfolio.go) 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 index 2092db7..d64af99 100644 --- a/internal/lkgateway/lkportfolio.go +++ b/internal/lkgateway/lkportfolio.go @@ -72,16 +72,23 @@ func lkPaperType(cfi, name string) string { // (пусто — все). 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() + 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)) @@ -114,7 +121,40 @@ func lkPortfolioLines(ctx context.Context, rc *RuntimeConfig, depoPort string, o DepoAccount: p.AccountNum, Section: ps.section(), Storage: ps.storage(), PaperType: lkPaperType(cfi, p.SecurityName), }) } - return lines, skipped, nil + 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»). @@ -216,6 +256,7 @@ type LKPortfolioSyncState struct { Lines int Skipped []string Err string + Nothing string // отправлять нечего (нет остатков или все бумаги без номера гос. регистрации) — не ошибка hash string manual bool // ручная заливка («Залить сейчас»), не постоянное заполнение } @@ -225,23 +266,53 @@ var lkPortfolioState = struct { m map[string]*LKPortfolioSyncState }{m: map[string]*LKPortfolioSyncState{}} -// lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force). +// lkPortfolioRun — проходы по очереди: фоновый и «Заполнить сейчас» не отправляют один портфель дважды +// (23.10.2026 при включении «для всех» 481 договор ушёл дважды). +var lkPortfolioRun sync.Mutex + +// 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 } + 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) + all, err := lkPortfolioAllPairs(ctx, rc, db) if err != nil { log.Printf("lk-portfolio: список клиентов (все): %v", err) return } - prs = all + 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 { - lines, skipped, err := lkPortfolioLines(ctx, rc, pr.Depo, nil) + 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 { @@ -260,20 +331,25 @@ func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) { raw, _ := json.Marshal(lines) sum := sha256.Sum256(raw) h := hex.EncodeToString(sum[:]) - same := h == s.hash + same := h == s.hash // отпечаток ставится только после принятой отправки или «нечего отправлять» + if same { + s.Err = "" // DEPO снова читается, портфель тот же — прежняя ошибка чтения снята + } + recent := !s.PushedAt.IsZero() && time.Since(s.PushedAt) < time.Minute lkPortfolioState.Unlock() - if same && !force { + if same && (!force || recent) { continue } if len(lines) == 0 { // ЛК отвечает 500 на пустой импорт — не шлём lkPortfolioState.Lock() - s.Skipped, s.Err, s.hash = skipped, lkPortfolioNothing(skipped), h + 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() - s.Skipped = skipped + prev := s.Err + s.Skipped, s.Nothing = skipped, "" switch { case perr != nil: s.Err = perr.Error() @@ -284,14 +360,53 @@ func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) { } msg := s.Err lkPortfolioState.Unlock() - if msg != "" { + 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) - } else { + 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 { @@ -310,6 +425,55 @@ func lkPortfolioStates() []LKPortfolioSyncState { 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 @@ -479,14 +643,8 @@ func lkPortfolioClients(ctx context.Context, rc *RuntimeConfig, q string) ([]LKP // lkPortfolioAllPairs — все открытые торговые счета DEPO с портфелем (режим «для всех // клиентов»): фильтр по префиксу счёта, договор ЛК выводится из счёта по шаблону, // портфель — PORT_NUM. Только MSSQL-база DEPO (как и поиск одного клиента). -func lkPortfolioAllPairs(ctx context.Context, rc *RuntimeConfig) ([]lkPortfolioPair, error) { - fs := rc.Snapshot().Fancy +func lkPortfolioAllPairs(ctx context.Context, rc *RuntimeConfig, db *sql.DB) ([]lkPortfolioPair, error) { 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 @@ -534,18 +692,22 @@ func lkPortfolioPushPair(ctx context.Context, rc *RuntimeConfig, pr lkPortfolioP } } lkPortfolioState.Unlock() - msg := "" + msg, nothing := "", "" if err != nil { msg = err.Error() } else if len(lines) == 0 { - msg = lkPortfolioNothing(skipped) + 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 = msg + s.Err, s.Nothing = msg, nothing + if nothing != "" { + s.Err = "" + } if msg == "" { raw, _ := json.Marshal(lines) sum := sha256.Sum256(raw) diff --git a/internal/lkgateway/lkportfolio_all_live_test.go b/internal/lkgateway/lkportfolio_all_live_test.go new file mode 100644 index 0000000..fd09de3 --- /dev/null +++ b/internal/lkgateway/lkportfolio_all_live_test.go @@ -0,0 +1,77 @@ +//go:build live + +package lkgateway + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "testing" + "time" +) + +// Остатки «для всех клиентов» одним запросом (Dp03) совпадают с запросом по клиенту (Dp01). Только чтение DEPO. +// PF_SETUP — копия setup.json (по умолчанию /var/lib/bj/.bj/setup.json). +func TestLKPortfolioAllLive(t *testing.T) { + path := os.Getenv("PF_SETUP") + if path == "" { + path = "/var/lib/bj/.bj/setup.json" + } + rc, err := NewRuntimeConfig(path) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + fs := rc.Snapshot().Fancy + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + t0 := time.Now() + prs, err := lkPortfolioAllPairs(ctx, rc, db) + if err != nil { + t.Fatal(err) + } + t1 := time.Now() + all, err := depoAllPositions(ctx, db) + if err != nil { + t.Fatal(err) + } + t2 := time.Now() + by := lkPortfolioByPort(all, prs) + rows := 0 + for _, p := range all { + rows += len(p) + } + t.Logf("договоров %d (%s); остатков %d по %d портфелям (%s); с остатками у договоров %d", + len(prs), t1.Sub(t0).Round(time.Millisecond), rows, len(all), t2.Sub(t1).Round(time.Millisecond), len(by)) + key := func(ps []DepoPosition) string { + var k []string + for _, p := range ps { + k = append(k, fmt.Sprintf("%s|%s|%g", strings.TrimSpace(p.AccountNum), strings.TrimSpace(p.ISIN), p.Quantity)) + } + sort.Strings(k) + return strings.Join(k, ";") + } + step, checked := len(prs)/60+1, 0 + var one time.Duration + for i := 0; i < len(prs); i += step { + pr := prs[i] + s := time.Now() + want, err := depoClientPositionsDB(ctx, db, pr.Depo) + one += time.Since(s) + if err != nil { + t.Fatalf("%s: %v", pr.Depo, err) + } + if a, b := key(want), key(by[pr.Depo]); a != b { + t.Errorf("%s ← %s: по клиенту %d строк, общим запросом %d\n %s\n %s", pr.LK, pr.Depo, len(want), len(by[pr.Depo]), a, b) + } + checked++ + } + if checked > 0 { + t.Logf("сверено договоров %d, запрос по клиенту в среднем %s", checked, (one / time.Duration(checked)).Round(time.Millisecond)) + } +} diff --git a/internal/lkgateway/lkportfolio_test.go b/internal/lkgateway/lkportfolio_test.go index 240516e..80e45ba 100644 --- a/internal/lkgateway/lkportfolio_test.go +++ b/internal/lkgateway/lkportfolio_test.go @@ -4,7 +4,9 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" + "time" ) func TestLKPaperTypeAndPairs(t *testing.T) { @@ -36,6 +38,57 @@ func TestLKContractByAccount(t *testing.T) { } } +// Остатки общего запроса раскладываются по портфелям пар как в Dp01: совпадение или «портфель + пробел». +func TestLKPortfolioByPort(t *testing.T) { + all := map[string][]DepoPosition{ + "БР-1001 Гравс Геннадий": {{AccountNum: "ВТНКЦ01", ISIN: "RU2", SecurityName: "Б"}, {AccountNum: "ВТНКЦ01", ISIN: "RU1", SecurityName: "А"}}, + "БР-10011 Другой": {{AccountNum: "ВТНКЦ02", ISIN: "RU3", SecurityName: "В"}}, + "БР-7": {{AccountNum: "ВТНКЦ07", ISIN: "RU4", SecurityName: "Г"}}, + } + by := lkPortfolioByPort(all, []lkPortfolioPair{{LK: "ДД-1", Depo: "БР-1001"}, {LK: "ДД-7", Depo: "БР-7"}, {LK: "ДД-9", Depo: "БР-9"}}) + if p := by["БР-1001"]; len(p) != 2 || p[0].ISIN != "RU1" || p[1].ISIN != "RU2" { + t.Fatalf("БР-1001: %+v", p) + } + if len(by["БР-7"]) != 1 || len(by["БР-9"]) != 0 || len(by) != 2 { + t.Fatalf("%+v", by) + } +} + +// Итог заполнения: «нечего отправлять» — не ошибка, все договоры — под раскрытием. +func TestLKPortfolioSummaryView(t *testing.T) { + lkPortfolioState.Lock() + saved := lkPortfolioState.m + now := time.Now() + lkPortfolioState.m = map[string]*LKPortfolioSyncState{ + "ДД-1": {LK: "ДД-1", Depo: "БР-1-1", CheckedAt: now, Nothing: lkPortfolioNothing([]string{"x"}), Skipped: []string{"x"}}, + "ДД-1006": {LK: "ДД-1006", Depo: "БР-1047", CheckedAt: now, PushedAt: now, Lines: 1}, + "ДД-1007": {LK: "ДД-1007", Depo: "БР-1048", CheckedAt: now, Err: "DEPO: i/o timeout"}, + "ДД-816": {LK: "ДД-816", Depo: "БР-859", CheckedAt: now, PushedAt: now, Lines: 3, manual: true}, + } + lkPortfolioState.Unlock() + defer func() { lkPortfolioState.Lock(); lkPortfolioState.m = saved; lkPortfolioState.Unlock() }() + + sm := lkPortfolioSummary() + if sm.Total != 3 || sm.Sent != 1 || sm.Nothing != 1 || sm.Errors != 1 || sm.Skipped != 1 || sm.Manual == nil || len(sm.Recent) != 1 { + t.Fatalf("%+v", sm) + } + a, err := newAdmin() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup")}) + body := rec.Body.String() + for _, want := range []string{"Итог заполнения", "Все договоры (3)", "Договоры с ошибкой (1)", "Последние обновления", "Заливка одному клиенту"} { + if !strings.Contains(body, want) { + t.Errorf("нет %q", want) + } + } + if strings.Contains(body, "ошибка — отправлять нечего") { + t.Error("«отправлять нечего» показано как ошибка") + } +} + // Маршруты карточки «Заполнение портфеля ДЕПО» зарегистрированы (29.09–22.10 их не было — 404). func TestLKPortfolioRoutes(t *testing.T) { mux := http.NewServeMux() diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 2ebe370..fd8af0b 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -288,6 +288,7 @@ func (s *Server) Run(ctx context.Context) error { // Монитор здоровья + systemd watchdog: следит за фоновыми горутинами, // поднимает алерт при зависании и «пинает» systemd, пока всё живо. healthRegister("poll-lk", time.Minute) + healthRegister("lk-portfolio", 2*time.Minute) // проход «для всех клиентов» — до 4 мин без отметки // Ключ продлеваем до запуска фоновых циклов: режим разработки (имитации и т.п.) должен быть // известен им с первого прохода. if licenseNeedsRenew(s.rc) { @@ -338,6 +339,8 @@ func (s *Server) Run(ctx context.Context) error { // Поллер заявок ЛК ЕСИА Финанс: раз в минуту забирает новые depo_m2m и // отправляет в НРД (или имитатору). Интервал 1 мин — время ответов регламентировано. go superviseLoop(ctx, "poll-lk", s.rc, s.pollLKClaims) + // Портфели ДЕПО из DEPO в ЛК: по списку — каждые 10 с, для всех клиентов — раз в 5 мин. + go superviseLoop(ctx, "lk-portfolio", s.rc, s.lkPortfolioLoop) // Авто-обновление справочника участников M2M раз в сутки (только реальный // ИШ). Тик суточный — в heartbeat-монитор не регистрируем, только recover. diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 0806f92..a352aaa 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -583,6 +583,8 @@ func HealthLabel(name string) string { switch name { case "poll-lk": return "Опрос заявок ЛК ЕСИА Финанс" + case "lk-portfolio": + return "Заполнение портфеля ДЕПО в ЛК ЕСИА Финанс" case "poll-incoming": return "Опрос ответов НРД (ИШ)" case "consume-decisions": diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index cd4aaa4..d2c0a3d 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -677,7 +677,7 @@
Заполнение портфеля ДЕПО бумагами из DEPO {{if .Settings.LK.PortfolioSync.Enabled}}включено{{if .Settings.LK.PortfolioSync.AllClients}} — все клиенты{{else}} — по списку{{end}}{{else}}выключено{{end}} -

Каждые 10 секунд, в том же проходе, что и приём заявок, bj сверяет портфель клиента в DEPO и отправляет его в ЛК (/portfolio/import, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).

+

Для клиентов из списка — каждые 10 секунд, для всех клиентов — раз в 5 минут (одним запросом к DEPO) bj сверяет портфель клиента в DEPO и отправляет его в ЛК (/portfolio/import, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).

{{if .Settings.LK.PortfolioSync.Enabled}} @@ -746,7 +746,14 @@ q.addEventListener('focus',function(){ if(!list.innerHTML) load(); }); })(); - {{range lkPortfolioStates}}
{{.LK}} ← {{.Depo}}: {{if .Err}}ошибка — {{.Err}}{{else if .PushedAt.IsZero}}сверено {{.CheckedAt.Format "15:04:05"}}, отправки ещё не было{{else}}отправлено {{.PushedAt.Format "02.01 15:04:05"}} ({{.Lines}} бумаг), сверено {{.CheckedAt.Format "15:04:05"}}{{end}}{{if .Skipped}}
пропущено бумаг: {{len .Skipped}} — без номера гос. регистрации{{range .Skipped}}
{{.}}
{{end}}
{{end}}
{{end}} + {{with lkPortfolioSummary}}{{if or .Manual .Total}}
+ {{with .Manual}}
Заливка одному клиенту: {{template "lkpfRow" .}}
{{end}} + {{if .Total}}
Итог заполнения: договоров {{.Total}} · отправлено в ЛК {{.Sent}} · нечего отправлять {{.Nothing}}{{if .Errors}} · с ошибкой {{.Errors}}{{end}}{{if .Skipped}} · бумаг без номера гос. регистрации {{.Skipped}}{{end}}
+
{{if not .LastPush.IsZero}}последняя отправка {{.LastPush.Format "02.01 15:04:05"}} · {{end}}последняя сверка {{.LastCheck.Format "15:04:05"}}
+ {{if .Recent}}
Последние обновления:
{{range .Recent}}
{{template "lkpfRow" .}}
{{end}}{{end}} + {{if .Failed}}
Договоры с ошибкой ({{len .Failed}}){{range .Failed}}
{{template "lkpfRow" .}}
{{end}}
{{end}} +
Все договоры ({{.Total}}){{range .All}}
{{template "lkpfRow" .}}
{{end}}
{{end}} +
{{end}}{{end}}
Тест «боевой ЛК → тестовый контур НРД» {{if .Settings.LK.RealNSDTest.Enabled}}включён{{else}}выключен{{end}} @@ -1144,3 +1151,5 @@ })(); {{end}} +{{/* строка договора в итоге заполнения портфеля ДЕПО (LKPortfolioSyncState) */}} +{{define "lkpfRow"}}{{.LK}} ← {{.Depo}}: {{if .Err}}ошибка — {{.Err}}{{else if .Nothing}}{{.Nothing}}{{else if .PushedAt.IsZero}}сверено {{.CheckedAt.Format "15:04:05"}}, отправки ещё не было{{else}}отправлено {{.PushedAt.Format "02.01 15:04:05"}} ({{.Lines}} бумаг), сверено {{.CheckedAt.Format "15:04:05"}}{{end}}{{if .Skipped}}
пропущено бумаг: {{len .Skipped}} — без номера гос. регистрации{{range .Skipped}}
{{.}}
{{end}}
{{end}}{{end}}