feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk) - трансферные таблицы FTV_M2M: создание при установке, события, справочник участников - сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO - боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК - онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML) - модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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,
|
||||
"SELECT TOP 1 PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM=@p1 OR PORT_NUM LIKE @p2",
|
||||
contract, 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()
|
||||
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
|
||||
q := `SELECT 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 (p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2)
|
||||
AND CAST(rst.K AS float) <> 0
|
||||
ORDER BY v.NAME`
|
||||
rows, err := conn.QueryContext(cctx, q, contract, contract+" %")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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(`SELECT TOP (%d) p.PORT_NUM, COUNT(DISTINCT rst.VALUE_ID)
|
||||
FROM OD_CLIENTS_PORTS p
|
||||
JOIN OD_CLIENTS c ON c.ID = p.CLIENT
|
||||
JOIN OD_RESTS rst ON rst.REG_1 = c.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`, limit)
|
||||
rows, err := conn.QueryContext(cctx, q, search, "%"+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(
|
||||
"SELECT DISTINCT TOP (%d) PORT_NUM FROM OD_CLIENTS_PORTS WHERE PORT_NUM LIKE @p1 ORDER BY PORT_NUM", 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()
|
||||
}
|
||||
Reference in New Issue
Block a user