Files
Bridge-and-Join-s/internal/lkgateway/depodb.go
T
fontvielle a0ffbfa721 ЛК: портфель ДЕПО «для всех клиентов» — одно соединение и один запрос к DEPO, свой цикл раз в 5 мин; итог в настройках свёрнут
На 9 после включения «для всех» (23.10.2026) каждый проход открывал по два
соединения с DEPO на каждый из 1158 договоров — около 50 входов в MSSQL в
секунду без перерыва, а проход шёл в цикле опроса заявок ЛК и задерживал его.

- Остатки всех клиентов — одним запросом (fsql.Dp03), одно соединение с DEPO
  на проход; по списку — запрос по клиенту на том же соединении. На боевой
  DEPO: общий запрос 127 мс, совпадает с запросом по клиенту (58 договоров).
- Свой цикл lkPortfolioLoop (здоровье «lk-portfolio»): по списку — каждые
  10 с, для всех клиентов — раз в 5 мин. Опрос заявок ЛК больше не ждёт.
- Проходы по очереди: фоновый и «Заполнить сейчас» не шлют один портфель
  дважды; принудительный проход пропускает отправленные за последнюю минуту.
- ЛК не отвечает — проход прерывается; одна и та же ошибка отправки не
  повторяется в журнале каждый проход. Ошибка чтения DEPO снимается, когда
  портфель снова читается без изменений.
- Настройки: итог (договоров, отправлено, нечего отправлять, с ошибкой),
  последние 5 отправок; договоры с ошибкой и все договоры — под раскрытием.
  «Отправлять нечего» — не ошибка (поле Nothing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
2026-10-23 14:45:16 +03:00

303 lines
10 KiB
Go

package lkgateway
import (
"context"
"database/sql"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
"strings"
"sync"
"time"
)
// Чтение РЕАЛЬНЫХ портфелей клиентов из депозитарной базы Fancy (BJ_DEPO_DATA).
// Только SELECT — боевая база учёта, ничего не меняем.
//
// Найденная цепочка связи:
//
// OD_CLIENTS_PORTS.PORT_NUM договор клиента («БР-1001 Гравс Геннадий»)
// ↓ CLIENT
// OD_CLIENTS.ID счёт депо клиента (В00000957 / ВТНКЦ00000957)
// ↓ = OD_RESTS.REG_1 ← ключ связи остатков со счётом
// OD_RESTS K = количество, BAL_ACC (836 рос. / 318 иностр.),
// BLOCK_DATE/BLOCK_REASON = блокировка
// ↓ VALUE_ID
// OD_VALUES бумага: NAME + ISIN (V_TYPE=2 — ценные бумаги)
//
// Важно: numeric-колонки читаем через CAST(... AS float) — иначе драйвер отдаёт 0.
// DepoPosition — позиция клиента по данным депозитарного учёта.
type DepoPosition struct {
Contract string // номер договора (PORT_NUM)
ClientName string // ФИО из договора
AccountNum string // счёт депо (OD_CLIENTS.NUMBER)
ISIN string //
SecurityName string //
Quantity float64 // остаток (OD_RESTS.K)
Blocked float64 // из них заблокировано (по BLOCK_REASON)
BlockReason string //
BalAcc int // балансовый счёт (836 — рос., 318 — иностр.)
}
// depoContractExists проверяет, есть ли такой договор в депозитарии.
func depoContractExists(ctx context.Context, s FancyMSSQLSettings, contract string) (string, bool) {
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return "", false
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
var full string
err = conn.QueryRowContext(cctx,
fsql.QDb01,
contract, likeEscape(contract)+" %").Scan(&full)
if err != nil {
return "", false
}
return full, true
}
// depoClientPositions читает реальный портфель клиента по номеру договора.
// contract — «БР-1001» либо полная строка «БР-1001 Гравс Геннадий».
func depoClientPositions(ctx context.Context, s FancyMSSQLSettings, contract string) ([]DepoPosition, error) {
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
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()
q := fsql.Dp01
rows, err := conn.QueryContext(cctx, q, contract, likeEscape(contract)+" %")
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() {
var p DepoPosition
if err := rows.Scan(&p.Contract, &p.AccountNum, &p.ISIN, &p.SecurityName,
&p.Quantity, &p.BlockReason, &p.BalAcc); err != nil {
return out, err
}
// ФИО зашито в номер договора: «БР-1001 Гравс Геннадий»
if i := strings.Index(p.Contract, " "); i > 0 {
p.ClientName = strings.TrimSpace(p.Contract[i:])
}
if p.BlockReason != "" {
p.Blocked = p.Quantity
}
out = append(out, p)
}
return out, rows.Err()
}
// depoPositionsAsClient переводит позиции депозитария в вид, который отдаётся в
// Личный кабинет (агрегируя одинаковые бумаги по разным счетам клиента).
func depoPositionsAsClient(pos []DepoPosition) []ClientPosition {
type agg struct {
p ClientPosition
accts map[string]bool
}
byISIN := map[string]*agg{}
var order []string
for _, d := range pos {
key := strings.ToUpper(d.ISIN)
a, ok := byISIN[key]
if !ok {
a = &agg{p: ClientPosition{
ISIN: d.ISIN,
SecurityCode: d.ISIN,
SecurityName: d.SecurityName,
ClientName: d.ClientName,
}, accts: map[string]bool{}}
byISIN[key] = a
order = append(order, key)
}
a.p.Quantity += d.Quantity
a.p.Blocked += d.Blocked
if d.AccountNum != "" && !a.accts[d.AccountNum] {
a.accts[d.AccountNum] = true
if a.p.AccountID == "" {
a.p.AccountID = d.AccountNum
} else {
a.p.AccountID += ", " + d.AccountNum
}
}
}
out := make([]ClientPosition, 0, len(order))
for _, k := range order {
a := byISIN[k]
a.p.Available = a.p.Quantity - a.p.Blocked
a.p.UpdatedAt = time.Now().Format("02.01.2006 15:04")
out = append(out, a.p)
}
// Иностранные бумаги видны в портфеле, но к переводу недоступны.
return markTransferable(out)
}
// DepoContract — реальный клиент депозитария для списка в админке ЛК.
type DepoContract struct {
Contract string `json:"contract"` // «БР-1001 Гравс Геннадий»
ClientName string `json:"name"` // ФИО (если есть в номере договора)
Securities int `json:"securities"` // сколько бумаг с ненулевым остатком
}
// depoListContracts — реальные клиенты депозитария с ненулевыми остатками.
// search — часть номера договора или ФИО (пусто = первые limit по алфавиту).
func depoListContracts(ctx context.Context, s FancyMSSQLSettings, search string, limit int) ([]DepoContract, error) {
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if limit <= 0 || limit > 500 {
limit = 100
}
q := fmt.Sprintf(fsql.Dp02, limit)
rows, err := conn.QueryContext(cctx, q, search, "%"+likeEscape(search)+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var out []DepoContract
for rows.Next() {
var c DepoContract
if err := rows.Scan(&c.Contract, &c.Securities); err != nil {
return out, err
}
if i := strings.Index(c.Contract, " "); i > 0 {
c.ClientName = strings.TrimSpace(c.Contract[i:])
}
out = append(out, c)
}
return out, rows.Err()
}
// depoFindContracts ищет договоры клиентов по части номера/ФИО (для админки).
func depoFindContracts(ctx context.Context, s FancyMSSQLSettings, like string, limit int) ([]string, error) {
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil, err
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := conn.QueryContext(cctx, fmt.Sprintf(
fsql.QDb02, limit),
"%"+like+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return out, err
}
out = append(out, s)
}
return out, rows.Err()
}
// likeEscape — экранирование шаблона LIKE. Номер договора приходит от клиента, и
// знаки % _ [ в нём должны означать сами себя, а не подстановку: иначе договор
// вида «БР-1%» совпал бы с чужими счетами.
func likeEscape(v string) string {
r := strings.NewReplacer("[", "[[]", "%", "[%]", "_", "[_]")
return r.Replace(v)
}
// ---- подсказки в поиске клиентов (админка ЛК) ----
// Список договоров нужен на каждой загрузке страницы «Клиенты»: браузер сам
// показывает выпадающий список по мере ввода (<datalist>), без скриптов на
// странице. Запрос дешёвый, но их 1300+, поэтому держим ответ в памяти.
var depoSuggestCache = struct {
mu sync.Mutex
list []string
at time.Time
}{}
// depoSuggestContracts — все номера договоров депозитария для подсказок поиска.
func depoSuggestContracts(ctx context.Context, s FancyMSSQLSettings) []string {
if strings.TrimSpace(s.Server) == "" {
return nil
}
depoSuggestCache.mu.Lock()
if time.Since(depoSuggestCache.at) < 10*time.Minute && depoSuggestCache.list != nil {
list := depoSuggestCache.list
depoSuggestCache.mu.Unlock()
return list
}
depoSuggestCache.mu.Unlock()
conn, err := fancyOpen(s, s.DepoDB())
if err != nil {
return nil
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
rows, err := conn.QueryContext(cctx,
fsql.QDb03)
if err != nil {
return nil
}
defer rows.Close()
var out []string
for rows.Next() {
var v string
if rows.Scan(&v) != nil {
break
}
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
if rows.Err() != nil && len(out) == 0 {
return nil
}
depoSuggestCache.mu.Lock()
depoSuggestCache.list, depoSuggestCache.at = out, time.Now()
depoSuggestCache.mu.Unlock()
return out
}