Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной системы было нельзя: в DEPO он от 31.05.2026, в SPECTRE — от 18.07.2026. Берём официальный курс Банка России (открытый XML, без ключа — тем же каналом пользуется Инвест-хаб) и складываем по дням в lk.fx_rates: без интернета кабинет считает по последнему известному курсу, а не отказывается оценивать. Источник переключается в настройках («Данные кабинета»), там же кнопка «Обновить курсы сейчас». Портфель БР-719: 560,3 млн ₽ против 522 млн по старому курсу. История стоимости. Ни QUIK, ни SPECTRE её не хранят, поэтому раз в день после закрытия торгов считаем стоимость каждого портфеля по экспорту QUIK и пишем строку в lk.portfolio_daily (257 портфелей за 14 секунд). Кабинет рисует по ним график на странице портфеля — SVG собирается на сервере, скриптов в кабинете по-прежнему нет. Режим по умолчанию — «автоматически»: ведём, если настроен экспорт QUIK, потому что задним числом историю не восстановить. Операции. Связка «JOIN OD_RESTS ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server разбирать список счетов на каждой строке журнала проводок: год данных читался 6–7 секунд. Обе стороны проводки выбираются заранее двумя ветвями UNION ALL, индексы IX_WIRING_D_REST_WIRDATE и IX_WIRING_K_REST_WIRDATE отрабатывают поиском — 89 мс на тех же 64 строках. Период по умолчанию вернули к году, ответ кэшируется на пять минут. Профиль: почта, телефон и прошлый вход из анкеты — клиент видит, куда с ним свяжется депозитарий, и заметит устаревшие данные. Правка только через менеджера. Заказ документов из кабинета убран совсем (форма была убрана раньше, теперь нет и обработчика). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package lkgateway
|
||||
|
||||
// cbrfx.go — официальные курсы валют Банка России.
|
||||
//
|
||||
// Оценивать валютные выпуски по курсу из учётной системы нельзя: в копии DEPO он
|
||||
// от 31.05.2026, в SPECTRE — от 18.07.2026, то есть отстаёт на месяцы. Банк России
|
||||
// публикует курсы открыто и без ключа (XML, windows-1251), тем же каналом ими
|
||||
// пользуется Инвест-хаб. Забранные курсы складываем в lk.fx_rates: в закрытом
|
||||
// контуре без интернета кабинет продолжит считать по последнему известному курсу,
|
||||
// а для архива на дату курс нужен именно на тот день.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
// cbrDailyURL — «Курсы валют на дату» (без date_req — на сегодня).
|
||||
const cbrDailyURL = "https://www.cbr.ru/scripts/XML_daily.asp"
|
||||
|
||||
// lkFxSchema — курсы валют по дням: source = cbr | accounting.
|
||||
const lkFxSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lk.fx_rates (
|
||||
day DATE NOT NULL, code VARCHAR(8) NOT NULL, rate DOUBLE PRECISION NOT NULL,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'cbr', fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (day, code))`
|
||||
|
||||
type cbrDoc struct {
|
||||
Date string `xml:"Date,attr"`
|
||||
Valute []struct {
|
||||
CharCode string `xml:"CharCode"`
|
||||
Nominal string `xml:"Nominal"`
|
||||
Value string `xml:"Value"`
|
||||
} `xml:"Valute"`
|
||||
}
|
||||
|
||||
// cbrFetch — курсы ЦБ на дату (нулевая дата = на сегодня). Возвращает дату, на
|
||||
// которую курс установлен: в выходные ЦБ отдаёт курс ближайшего рабочего дня.
|
||||
func cbrFetch(ctx context.Context, day time.Time) (time.Time, map[string]float64, error) {
|
||||
url := cbrDailyURL
|
||||
if !day.IsZero() {
|
||||
url += "?date_req=" + day.Format("02/01/2006")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "bj/1.0 (+depozitarij)")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return time.Time{}, nil, fmt.Errorf("Банк России ответил %s", resp.Status)
|
||||
}
|
||||
var doc cbrDoc
|
||||
dec := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20))
|
||||
// Ответ в windows-1251; коды валют латиницей, но без раскодировки Go
|
||||
// откажется разбирать документ целиком.
|
||||
dec.CharsetReader = func(enc string, in io.Reader) (io.Reader, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(enc)) {
|
||||
case "windows-1251", "cp1251":
|
||||
return charmap.Windows1251.NewDecoder().Reader(in), nil
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
at, _ := time.ParseInLocation("02.01.2006", doc.Date, time.Local)
|
||||
out := make(map[string]float64, len(doc.Valute))
|
||||
for _, v := range doc.Valute {
|
||||
code := strings.ToUpper(strings.TrimSpace(v.CharCode))
|
||||
val, err1 := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v.Value), ",", "."), 64)
|
||||
nom, err2 := strconv.ParseFloat(strings.TrimSpace(v.Nominal), 64)
|
||||
if code == "" || err1 != nil || val <= 0 {
|
||||
continue
|
||||
}
|
||||
if err2 != nil || nom <= 0 {
|
||||
nom = 1
|
||||
}
|
||||
out[code] = val / nom // курс за одну единицу валюты
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return at, nil, fmt.Errorf("Банк России не вернул ни одного курса")
|
||||
}
|
||||
return at, out, nil
|
||||
}
|
||||
|
||||
// ---- хранение ----
|
||||
|
||||
// lkFxSave — сложить курсы дня в базу (перезаписывая курс того же дня).
|
||||
func lkFxSave(ctx context.Context, rc *RuntimeConfig, day time.Time, source string, rates map[string]float64) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil || day.IsZero() || len(rates) == 0 {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
for code, rate := range rates {
|
||||
_, _ = db.ExecContext(cctx, `INSERT INTO lk.fx_rates(day, code, rate, source, fetched_at)
|
||||
VALUES ($1,$2,$3,$4, now())
|
||||
ON CONFLICT (day, code) DO UPDATE SET rate = EXCLUDED.rate, source = EXCLUDED.source, fetched_at = now()`,
|
||||
day.Format("2006-01-02"), code, rate, source)
|
||||
}
|
||||
}
|
||||
|
||||
// lkFxLoad — последний известный курс каждой валюты не позже дня day
|
||||
// (нулевая дата = последний известный вообще).
|
||||
func lkFxLoad(ctx context.Context, rc *RuntimeConfig, day time.Time) map[string]LKFxRate {
|
||||
out := map[string]LKFxRate{}
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
upto := "9999-12-31"
|
||||
if !day.IsZero() {
|
||||
upto = day.Format("2006-01-02")
|
||||
}
|
||||
rows, err := db.QueryContext(cctx, `SELECT DISTINCT ON (code) code, day, rate
|
||||
FROM lk.fx_rates WHERE day <= $1 ORDER BY code, day DESC`, upto)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var at time.Time
|
||||
var rate float64
|
||||
if rows.Scan(&code, &at, &rate) == nil && rate > 0 {
|
||||
out[strings.ToUpper(code)] = LKFxRate{Rate: rate, At: at}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- курс на сегодня ----
|
||||
|
||||
var cbrCache = struct {
|
||||
mu sync.Mutex
|
||||
at time.Time // когда ходили в ЦБ
|
||||
day time.Time // на какую дату курс
|
||||
m map[string]float64
|
||||
err string
|
||||
}{}
|
||||
|
||||
// cbrToday — курсы ЦБ на сегодня с кэшем на час. Ошибку запоминаем, чтобы
|
||||
// в закрытом контуре не ходить в недоступный интернет на каждой странице.
|
||||
func cbrToday(ctx context.Context, rc *RuntimeConfig) (map[string]LKFxRate, string) {
|
||||
cbrCache.mu.Lock()
|
||||
fresh := time.Since(cbrCache.at) < time.Hour
|
||||
m, day, cerr := cbrCache.m, cbrCache.day, cbrCache.err
|
||||
cbrCache.mu.Unlock()
|
||||
if !fresh {
|
||||
at, rates, err := cbrFetch(ctx, time.Time{})
|
||||
cbrCache.mu.Lock()
|
||||
cbrCache.at = time.Now()
|
||||
if err != nil {
|
||||
cbrCache.err = err.Error()
|
||||
} else {
|
||||
cbrCache.m, cbrCache.day, cbrCache.err = rates, at, ""
|
||||
}
|
||||
m, day, cerr = cbrCache.m, cbrCache.day, cbrCache.err
|
||||
cbrCache.mu.Unlock()
|
||||
if err == nil {
|
||||
lkFxSave(ctx, rc, at, "cbr", rates)
|
||||
}
|
||||
}
|
||||
out := make(map[string]LKFxRate, len(m))
|
||||
for code, rate := range m {
|
||||
out[code] = LKFxRate{Rate: rate, At: day}
|
||||
}
|
||||
return out, cerr
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -206,6 +207,10 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
"read_only": readOnly,
|
||||
"total_value": total, // оценка портфеля, 0 — цен нет
|
||||
"priced": priced, // по скольким бумагам есть цена
|
||||
// Контакты из анкеты: клиент видит, куда депозитарий будет писать и звонить,
|
||||
// и сразу заметит, если данные устарели. Менять их в кабинете нельзя.
|
||||
"email": u.Email, "phone": u.Phone,
|
||||
"last_login": lkTimeRU(u.LastLoginAt),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -275,6 +280,30 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
// Портфель клиента как у брокера: view=online — по экспорту QUIK (что есть сейчас), если
|
||||
// в QUIK клиента нет — по брокерскому учёту на сегодня; view=archive&date=YYYY-MM-DD —
|
||||
// портфель на дату по брокерскому учёту (SPECTRE).
|
||||
// История стоимости портфеля: ежедневные срезы (lksnapshot.go), для графика.
|
||||
mux.HandleFunc("/api/lk/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
u, ok := current(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days := 180
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && v > 0 {
|
||||
days = v
|
||||
}
|
||||
points, err := lkSnapshotHistory(r.Context(), rc, u.Contract, days)
|
||||
if err != nil {
|
||||
lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
set := rc.Snapshot().LK
|
||||
hour, min := lkSnapshotTime(set)
|
||||
lkJSON(w, http.StatusOK, map[string]any{"points": points, "on": lkSnapshotEnabled(rc),
|
||||
"at": fmt.Sprintf("%02d:%02d", hour, min), "days": days})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
+146
-63
@@ -313,7 +313,36 @@ type LKOperationRow struct {
|
||||
}
|
||||
|
||||
// lkArchiveOperations — сделки и движения по портфельным счетам договора за период.
|
||||
// Лента операций за период не меняется в течение дня, а год данных из журнала
|
||||
// проводок читается секундами: держим готовый ответ пять минут.
|
||||
var opsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]opsCached
|
||||
}{m: map[string]opsCached{}}
|
||||
|
||||
type opsCached struct {
|
||||
at time.Time
|
||||
rows []LKOperationRow
|
||||
}
|
||||
|
||||
func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
|
||||
key := lkContractNumber(contract) + "|" + from.Format("2006-01-02") + "|" + to.Format("2006-01-02")
|
||||
opsCache.mu.Lock()
|
||||
c, ok := opsCache.m[key]
|
||||
opsCache.mu.Unlock()
|
||||
if ok && time.Since(c.at) < 5*time.Minute {
|
||||
return c.rows, nil
|
||||
}
|
||||
rows, err := lkArchiveOperationsLoad(ctx, s, contract, from, to)
|
||||
if err == nil {
|
||||
opsCache.mu.Lock()
|
||||
opsCache.m[key] = opsCached{at: time.Now(), rows: rows}
|
||||
opsCache.mu.Unlock()
|
||||
}
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func lkArchiveOperationsLoad(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) {
|
||||
c, ok := spectreContractFor(ctx, s, contract)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract))
|
||||
@@ -351,19 +380,53 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str
|
||||
toEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Три обхода журнала проводок независимы, а каждый по году данных идёт секунды:
|
||||
// выполняем их разом, иначе клиент ждёт их сумму.
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var cashOut []LKOperationRow
|
||||
var proceeds map[string][2]float64
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
proceeds = spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd)
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cashOut = spectreCashOps(cctx, conn, cashIDs, fromDay, toEnd)
|
||||
}()
|
||||
|
||||
if len(secIDs) > 0 {
|
||||
in := strings.Join(secIDs, ",")
|
||||
wrows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float),
|
||||
ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'')
|
||||
FROM OD_WIRING w
|
||||
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
|
||||
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
|
||||
LEFT JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
|
||||
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
|
||||
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
|
||||
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND (w.D_AMOUNT <> 0 OR w.K_AMOUNT <> 0)
|
||||
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
|
||||
// Сторона проводки выбирается заранее двумя ветвями UNION ALL: связка
|
||||
// «JOIN … ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server
|
||||
// разбирать список счетов на каждой строке журнала — год данных читался
|
||||
// 6–7 секунд, сейчас — десятки миллисекунд (оба индекса по D_REST/K_REST
|
||||
// с WIRDATE отрабатывают поиском).
|
||||
wrows, err := conn.QueryContext(cctx, fmt.Sprintf(`
|
||||
WITH w AS (
|
||||
SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_AMOUNT, wr.K_AMOUNT, wr.NAME,
|
||||
wr.D_REST AS sec_rest, wr.K_REST AS money_rest
|
||||
FROM OD_WIRING wr
|
||||
WHERE wr.D_REST IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000
|
||||
AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND (wr.D_AMOUNT <> 0 OR wr.K_AMOUNT <> 0)
|
||||
UNION ALL
|
||||
SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_AMOUNT, wr.K_AMOUNT, wr.NAME,
|
||||
wr.K_REST, wr.D_REST
|
||||
FROM OD_WIRING wr
|
||||
WHERE wr.K_REST IN (%[1]s) AND wr.D_REST NOT IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000
|
||||
AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND (wr.D_AMOUNT <> 0 OR wr.K_AMOUNT <> 0))
|
||||
SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float),
|
||||
CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float),
|
||||
ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'')
|
||||
FROM w
|
||||
LEFT JOIN OD_RESTS sr ON sr.ID = w.sec_rest
|
||||
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
|
||||
LEFT JOIN OD_RESTS mr ON mr.ID = w.money_rest
|
||||
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
|
||||
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("проводки: %v", err)
|
||||
}
|
||||
@@ -405,7 +468,7 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str
|
||||
wrows.Close()
|
||||
// При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка —
|
||||
// на счёте ВЫРУЧКА. Клиенту показываем именно её.
|
||||
proceeds := spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd)
|
||||
wg.Wait()
|
||||
for _, k := range order {
|
||||
a := agg[k]
|
||||
if a.Kind == "продажа" || a.Kind == "погашение" {
|
||||
@@ -419,50 +482,10 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str
|
||||
out = append(out, *a)
|
||||
}
|
||||
}
|
||||
// Деньги: пополнения и выводы — проводки по кассовым счетам с чужой стороной вне портфеля договора.
|
||||
if len(cashIDs) > 0 {
|
||||
in := strings.Join(cashIDs, ",")
|
||||
crows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'')
|
||||
FROM OD_WIRING w
|
||||
JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
|
||||
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
|
||||
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
|
||||
AND w.WIRDATE >= @f AND w.WIRDATE < @t
|
||||
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
|
||||
if err == nil {
|
||||
cashSet := map[string]bool{}
|
||||
for _, id := range cashIDs {
|
||||
cashSet[id] = true
|
||||
}
|
||||
for crows.Next() {
|
||||
var wd time.Time
|
||||
var eq, dSum, kSum float64
|
||||
var dRest, kRest int64
|
||||
var wname, cur string
|
||||
if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(wname)
|
||||
var kind, side string
|
||||
switch {
|
||||
case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"):
|
||||
kind, side = "пополнение", "in"
|
||||
case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"):
|
||||
kind, side = "вывод денег", "out"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
sum := kSum
|
||||
if cashSet[fmt.Sprint(dRest)] {
|
||||
sum = dSum
|
||||
}
|
||||
out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства",
|
||||
Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq})
|
||||
}
|
||||
crows.Close()
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
mu.Lock()
|
||||
out = append(out, cashOut...)
|
||||
mu.Unlock()
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
di, _ := time.Parse("02.01.2006", out[i].Date)
|
||||
dj, _ := time.Parse("02.01.2006", out[j].Date)
|
||||
@@ -471,6 +494,57 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spectreCashOps — пополнения и выводы денег по кассовым счетам договора.
|
||||
func spectreCashOps(ctx context.Context, conn *sql.DB, cashIDs []string, fromDay, toEnd time.Time) []LKOperationRow {
|
||||
if len(cashIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []LKOperationRow
|
||||
in := strings.Join(cashIDs, ",")
|
||||
crows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'')
|
||||
FROM OD_WIRING w
|
||||
JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END
|
||||
LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID
|
||||
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
|
||||
AND w.WIRDATE >= @f AND w.WIRDATE < @t
|
||||
ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer crows.Close()
|
||||
cashSet := map[string]bool{}
|
||||
for _, id := range cashIDs {
|
||||
cashSet[id] = true
|
||||
}
|
||||
for crows.Next() {
|
||||
var wd time.Time
|
||||
var eq, dSum, kSum float64
|
||||
var dRest, kRest int64
|
||||
var wname, cur string
|
||||
if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(wname)
|
||||
var kind, side string
|
||||
switch {
|
||||
case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"):
|
||||
kind, side = "пополнение", "in"
|
||||
case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"):
|
||||
kind, side = "вывод денег", "out"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
sum := kSum
|
||||
if cashSet[fmt.Sprint(dRest)] {
|
||||
sum = dSum
|
||||
}
|
||||
out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства",
|
||||
Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// spectreDealKind — вид операции по названию проводки (рецепт хаба). Пусто — не показываем.
|
||||
func spectreDealKind(name string, onDebit bool) string {
|
||||
n := strings.ToLower(name)
|
||||
@@ -515,13 +589,22 @@ func spectreProceeds(ctx context.Context, conn *sql.DB, doc int64, from, to time
|
||||
return out
|
||||
}
|
||||
in := strings.Join(ids, ",")
|
||||
wrows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'')
|
||||
FROM OD_WIRING w
|
||||
JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.K_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END
|
||||
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2
|
||||
WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000
|
||||
AND w.WIRDATE >= @f AND w.WIRDATE < @t AND w.EQ_ <> 0`, in), sql.Named("f", from), sql.Named("t", to))
|
||||
wrows, err := conn.QueryContext(ctx, fmt.Sprintf(`
|
||||
WITH w AS (
|
||||
SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_REST AS sec_rest
|
||||
FROM OD_WIRING wr
|
||||
WHERE wr.K_REST IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000
|
||||
AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND wr.EQ_ <> 0
|
||||
UNION ALL
|
||||
SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.K_REST
|
||||
FROM OD_WIRING wr
|
||||
WHERE wr.D_REST IN (%[1]s) AND wr.K_REST NOT IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000
|
||||
AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND wr.EQ_ <> 0)
|
||||
SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST,
|
||||
CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'')
|
||||
FROM w
|
||||
LEFT JOIN OD_RESTS sr ON sr.ID = w.sec_rest
|
||||
LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2`, in), sql.Named("f", from), sql.Named("t", to))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package lkgateway
|
||||
|
||||
// lkdata.go — карточка «Данные кабинета» в настройках: откуда берём курсы валют
|
||||
// и ведём ли ежедневный срез стоимости портфелей (для графика у клиента).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LKDataView — состояние источников данных кабинета для страницы настроек.
|
||||
type LKDataView struct {
|
||||
FXSource string // cbr | accounting
|
||||
FXAt string // дата последнего известного курса
|
||||
FXCount int // сколько валют в последнем комплекте
|
||||
FXNote string // пояснение/ошибка связи с Банком России
|
||||
SnapshotMode string // "" (авто) | on | off
|
||||
SnapshotOn bool // ведём ли по факту
|
||||
SnapshotAt string // ЧЧ:ММ
|
||||
Snapshots int // сколько дней истории накоплено
|
||||
SnapLast string // последний снятый срез
|
||||
SnapRows int // строк в последнем срезе
|
||||
}
|
||||
|
||||
// lkDataView — собрать состояние без обращения в интернет (карточку открывают часто).
|
||||
func lkDataView(rc *RuntimeConfig) LKDataView {
|
||||
s := rc.Snapshot().LK
|
||||
hour, min := lkSnapshotTime(s)
|
||||
v := LKDataView{FXSource: strings.TrimSpace(s.FXSource), SnapshotMode: strings.TrimSpace(s.SnapshotMode),
|
||||
SnapshotOn: lkSnapshotEnabled(rc), SnapshotAt: fmt.Sprintf("%02d:%02d", hour, min)}
|
||||
if v.FXSource == "" {
|
||||
v.FXSource = "cbr"
|
||||
}
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
v.FXNote = "база bj недоступна: " + err.Error()
|
||||
return v
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var day *time.Time
|
||||
var n int
|
||||
if db.QueryRowContext(ctx, `SELECT max(day) FROM lk.fx_rates`).Scan(&day) == nil && day != nil {
|
||||
v.FXAt = day.Format("02.01.2006")
|
||||
_ = db.QueryRowContext(ctx, `SELECT count(*) FROM lk.fx_rates WHERE day = $1`, *day).Scan(&n)
|
||||
v.FXCount = n
|
||||
}
|
||||
var sday *time.Time
|
||||
if db.QueryRowContext(ctx, `SELECT count(DISTINCT day), max(day) FROM lk.portfolio_daily`).Scan(&v.Snapshots, &sday) == nil && sday != nil {
|
||||
v.SnapLast = sday.Format("02.01.2006")
|
||||
_ = db.QueryRowContext(ctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`, *sday).Scan(&v.SnapRows)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// lkDataSave — POST /admin/setup/lk/data: источник курсов и ежедневный срез.
|
||||
func (h *setupHandlers) lkDataSave(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
switch r.FormValue("op") {
|
||||
case "fx_now": // проверить связь с Банком России и обновить курсы
|
||||
at, rates, err := cbrFetch(r.Context(), time.Time{})
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Банк России недоступен: "+err.Error()+". Курсы останутся прежними.")
|
||||
return
|
||||
}
|
||||
lkFxSave(r.Context(), h.rc, at, "cbr", rates)
|
||||
lkFxReset()
|
||||
setupFlash(w, r, fmt.Sprintf("Курсы Банка России на %s получены: валют %d.", at.Format("02.01.2006"), len(rates)))
|
||||
return
|
||||
case "snapshot_now": // снять срез стоимости прямо сейчас
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
n, err := lkSnapshotRun(ctx, h.rc, time.Now())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = n
|
||||
}()
|
||||
setupFlash(w, r, "Срез стоимости портфелей снимается — на 1300 договоров это несколько минут; обновите страницу.")
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().LK
|
||||
if v := strings.TrimSpace(r.FormValue("fx_source")); v == "cbr" || v == "accounting" {
|
||||
s.FXSource = v
|
||||
}
|
||||
switch v := strings.TrimSpace(r.FormValue("snapshot_mode")); v {
|
||||
case "", "on", "off":
|
||||
s.SnapshotMode = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("snapshot_at")); len(v) == 5 && v[2] == ':' {
|
||||
s.SnapshotAt = v
|
||||
}
|
||||
if err := h.rc.UpdateLK(s); err != nil {
|
||||
setupFlash(w, r, "Данные кабинета: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
lkFxReset()
|
||||
src := "Банк России"
|
||||
if s.FXSource == "accounting" {
|
||||
src = "учётная система"
|
||||
}
|
||||
msg := "Курсы валют: " + src + "."
|
||||
if s.SnapshotMode == "off" {
|
||||
msg += " Ежедневный срез выключен — график в кабинете обновляться не будет."
|
||||
} else {
|
||||
hour, min := lkSnapshotTime(s)
|
||||
msg += fmt.Sprintf(" Срез стоимости портфелей — ежедневно в %02d:%02d.", hour, min)
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
@@ -29,8 +29,35 @@ var fxCache = struct {
|
||||
m map[string]LKFxRate
|
||||
}{}
|
||||
|
||||
// lkFxRates — курсы валют (ключ — код валюты в верхнем регистре), кэш на час.
|
||||
func lkFxRates(ctx context.Context, s FancyMSSQLSettings) map[string]LKFxRate {
|
||||
// lkFxRates — курсы валют для оценки (ключ — код валюты в верхнем регистре).
|
||||
// Источник по умолчанию — Банк России (см. cbrfx.go); если интернета нет или
|
||||
// в настройках выбрана учётная система, берём OD_VALUES_RATES, а недостающие
|
||||
// валюты дополняем последним известным курсом из lk.fx_rates.
|
||||
func lkFxRates(ctx context.Context, rc *RuntimeConfig) map[string]LKFxRate {
|
||||
set := rc.Snapshot()
|
||||
out := map[string]LKFxRate{}
|
||||
if !strings.EqualFold(strings.TrimSpace(set.LK.FXSource), "accounting") {
|
||||
if cbr, cerr := cbrToday(ctx, rc); len(cbr) > 0 {
|
||||
return cbr
|
||||
} else if cerr != "" {
|
||||
// молча уходим на запасные источники: кабинет не должен падать
|
||||
// из-за недоступного сайта ЦБ
|
||||
_ = cerr
|
||||
}
|
||||
}
|
||||
for code, r := range lkFxAccounting(ctx, set.Fancy) {
|
||||
out[code] = r
|
||||
}
|
||||
for code, r := range lkFxLoad(ctx, rc, time.Time{}) {
|
||||
if old, ok := out[code]; !ok || r.At.After(old.At) {
|
||||
out[code] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lkFxAccounting — курсы из учётной системы (OD_VALUES_RATES, MARKET = 0), кэш на час.
|
||||
func lkFxAccounting(ctx context.Context, s FancyMSSQLSettings) map[string]LKFxRate {
|
||||
fxCache.mu.Lock()
|
||||
if fxCache.m != nil && time.Since(fxCache.at) < time.Hour {
|
||||
m := fxCache.m
|
||||
@@ -89,3 +116,13 @@ func lkFxFor(rates map[string]LKFxRate, cur string) LKFxRate {
|
||||
}
|
||||
return rates[c]
|
||||
}
|
||||
|
||||
// lkFxReset — сбросить кэши курсов (после смены источника или ручного обновления).
|
||||
func lkFxReset() {
|
||||
fxCache.mu.Lock()
|
||||
fxCache.m, fxCache.at = nil, time.Time{}
|
||||
fxCache.mu.Unlock()
|
||||
cbrCache.mu.Lock()
|
||||
cbrCache.at, cbrCache.err = time.Time{}, ""
|
||||
cbrCache.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func inClause(ids []int64) string {
|
||||
func parseRange(from, to string) (time.Time, time.Time) {
|
||||
fromT, err := time.Parse("2006-01-02", strings.TrimSpace(from))
|
||||
if err != nil {
|
||||
fromT = time.Now().AddDate(0, -3, 0) // по умолчанию — последние три месяца
|
||||
fromT = time.Now().AddDate(-1, 0, 0) // по умолчанию — последний год
|
||||
}
|
||||
toT, err := time.Parse("2006-01-02", strings.TrimSpace(to))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package lkgateway
|
||||
|
||||
// lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете.
|
||||
//
|
||||
// Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции
|
||||
// «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом
|
||||
// дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз
|
||||
// в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK
|
||||
// и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет
|
||||
// рисует график: чем дольше работает система, тем длиннее история.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const lkSnapshotSchema = `
|
||||
CREATE TABLE IF NOT EXISTS lk.portfolio_daily (
|
||||
day DATE NOT NULL, contract VARCHAR(64) NOT NULL,
|
||||
value DOUBLE PRECISION NOT NULL DEFAULT 0, cash DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
positions INT NOT NULL DEFAULT 0, priced INT NOT NULL DEFAULT 0,
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'quik', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (day, contract))`
|
||||
|
||||
// lkSnapshotEnabled — ведём ли историю стоимости. По умолчанию (режим не выбран)
|
||||
// ведём, если настроен экспорт QUIK: история накапливается только вперёд, и
|
||||
// упущенные дни потом не восстановить.
|
||||
func lkSnapshotEnabled(rc *RuntimeConfig) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(rc.Snapshot().LK.SnapshotMode)) {
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
_, err := nprDB(rc)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// lkSnapshotTime — когда снимаем срез (по умолчанию 20:30, после закрытия основной сессии).
|
||||
func lkSnapshotTime(s LKSettings) (hour, min int) {
|
||||
hour, min = 20, 30
|
||||
v := strings.TrimSpace(s.SnapshotAt)
|
||||
if len(v) == 5 && v[2] == ':' {
|
||||
h := int(v[0]-'0')*10 + int(v[1]-'0')
|
||||
m := int(v[3]-'0')*10 + int(v[4]-'0')
|
||||
if h >= 0 && h < 24 && m >= 0 && m < 60 {
|
||||
hour, min = h, m
|
||||
}
|
||||
}
|
||||
return hour, min
|
||||
}
|
||||
|
||||
// LKDailyPoint — точка графика стоимости портфеля.
|
||||
type LKDailyPoint struct {
|
||||
Day string `json:"day"` // 2026-09-18
|
||||
Value float64 `json:"value"` // бумаги, ₽
|
||||
Cash float64 `json:"cash"` // деньги в рублях
|
||||
Total float64 `json:"total"` // бумаги + деньги
|
||||
Positions int `json:"positions"`
|
||||
}
|
||||
|
||||
// lkSnapshotHistory — история стоимости портфеля за последние days дней.
|
||||
func lkSnapshotHistory(ctx context.Context, rc *RuntimeConfig, contract string, days int) ([]LKDailyPoint, error) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
num := lkContractNumber(contract)
|
||||
if num == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if days <= 0 || days > 3650 {
|
||||
days = 365
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
rows, err := db.QueryContext(cctx, `SELECT day, value, cash, positions FROM lk.portfolio_daily
|
||||
WHERE contract = $1 AND day >= current_date - $2::int ORDER BY day`, num, days)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []LKDailyPoint
|
||||
for rows.Next() {
|
||||
var p LKDailyPoint
|
||||
var day time.Time
|
||||
if rows.Scan(&day, &p.Value, &p.Cash, &p.Positions) != nil {
|
||||
continue
|
||||
}
|
||||
p.Day = day.Format("2006-01-02")
|
||||
p.Total = p.Value + p.Cash
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// lkSnapshotSave — записать срез одного портфеля за день (повторный запуск перезаписывает).
|
||||
func lkSnapshotSave(ctx context.Context, rc *RuntimeConfig, day time.Time, contract string, p *LKPortfolio) error {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(cctx, `INSERT INTO lk.portfolio_daily(day, contract, value, cash, positions, priced, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (day, contract) DO UPDATE SET value = EXCLUDED.value, cash = EXCLUDED.cash,
|
||||
positions = EXCLUDED.positions, priced = EXCLUDED.priced, source = EXCLUDED.source, created_at = now()`,
|
||||
day.Format("2006-01-02"), lkContractNumber(contract), p.Securities, p.CashRUB,
|
||||
len(p.Positions), p.Priced, p.Source)
|
||||
return err
|
||||
}
|
||||
|
||||
// lkSnapshotContracts — чьи портфели снимаем: договоры, по которым в экспорте QUIK
|
||||
// есть ненулевые позиции. Берём коды клиентов из экспорта и переводим их в номера
|
||||
// договоров депозитария (BR746 → БР-746), иначе пришлось бы перебирать 1300 договоров.
|
||||
func lkSnapshotContracts(ctx context.Context, rc *RuntimeConfig) []string {
|
||||
p, err := nprDB(rc)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
rows, err := p.Query(cctx, `SELECT DISTINCT "ClientCode" FROM "DepoLimits"
|
||||
WHERE "LimitKind" = 'T2' AND "CurrentBal" <> 0 AND "ClientCode" <> ''`)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
live := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if rows.Scan(&code) == nil {
|
||||
live[strings.ToUpper(strings.TrimSpace(code))] = true
|
||||
}
|
||||
}
|
||||
// номер договора знает только депозитарий: сопоставляем его коды с живыми в QUIK
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, full := range depoSuggestContracts(ctx, rc.Snapshot().Fancy) {
|
||||
num := lkContractNumber(full)
|
||||
if num == "" || seen[num] {
|
||||
continue
|
||||
}
|
||||
for _, code := range lkQuikCodes(num) {
|
||||
if live[strings.ToUpper(code)] {
|
||||
seen[num] = true
|
||||
out = append(out, num)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lkSnapshotRun — один проход: считает и сохраняет срез за день по всем портфелям.
|
||||
func lkSnapshotRun(ctx context.Context, rc *RuntimeConfig, day time.Time) (int, error) {
|
||||
contracts := lkSnapshotContracts(ctx, rc)
|
||||
saved := 0
|
||||
for _, num := range contracts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return saved, ctx.Err()
|
||||
default:
|
||||
}
|
||||
pf, err := lkOnlinePortfolio(ctx, rc, num)
|
||||
if err != nil || pf == nil {
|
||||
continue
|
||||
}
|
||||
if err := lkSnapshotSave(ctx, rc, day, num, pf); err == nil {
|
||||
saved++
|
||||
}
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// lkSnapshotLoop — раз в день в назначенное время снимает срез. Если сервер был
|
||||
// выключен и срез за сегодня не снят, а время уже прошло — снимаем при старте.
|
||||
func (s *Server) lkSnapshotLoop(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if !lkSnapshotEnabled(s.rc) {
|
||||
continue
|
||||
}
|
||||
set := s.rc.Snapshot()
|
||||
hour, min := lkSnapshotTime(set.LK)
|
||||
now := time.Now()
|
||||
if now.Hour() < hour || (now.Hour() == hour && now.Minute() < min) {
|
||||
continue
|
||||
}
|
||||
day := now.Truncate(24 * time.Hour)
|
||||
if lkSnapshotDone(ctx, s.rc, now) {
|
||||
continue
|
||||
}
|
||||
n, err := lkSnapshotRun(ctx, s.rc, day)
|
||||
if err != nil {
|
||||
log.Printf("кабинет: срез стоимости портфелей прерван: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("кабинет: срез стоимости портфелей за %s — %d договоров", now.Format("02.01.2006"), n)
|
||||
}
|
||||
}
|
||||
|
||||
// lkSnapshotDone — срез за этот день уже снят.
|
||||
func lkSnapshotDone(ctx context.Context, rc *RuntimeConfig, day time.Time) bool {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return true // без базы срез всё равно не сохранить, и жаловаться каждые 5 минут незачем
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var n int
|
||||
if db.QueryRowContext(cctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`,
|
||||
day.Format("2006-01-02")).Scan(&n) != nil {
|
||||
return true
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
@@ -100,7 +100,9 @@ var lkSchemaDDL = []string{
|
||||
|
||||
// ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj.
|
||||
// lkSchemaExtra — таблицы, добавленные позже основной схемы.
|
||||
func lkSchemaExtra() []string { return []string{lkDocSchema, lkAuditSchema} }
|
||||
func lkSchemaExtra() []string {
|
||||
return []string{lkDocSchema, lkAuditSchema, lkFxSchema, lkSnapshotSchema}
|
||||
}
|
||||
|
||||
func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error {
|
||||
db, err := lkDB(rc)
|
||||
|
||||
@@ -214,3 +214,11 @@ func lkFilterUsers(list []LKUser, search string) []LKUser {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// lkTimeRU — время для кабинета: «18.09.2026 19:40», пусто — если не было.
|
||||
func lkTimeRU(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
fxRates := lkFxRates(ctx, rc.Snapshot().Fancy) // для выпусков с валютным номиналом
|
||||
fxRates := lkFxRates(ctx, rc) // для выпусков с валютным номиналом
|
||||
// Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB,
|
||||
// корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами.
|
||||
rows, err := p.Query(cctx, `
|
||||
|
||||
@@ -269,6 +269,16 @@ type LKSettings struct {
|
||||
Test LKEndpoint `json:"lk_test"`
|
||||
Prod LKEndpoint `json:"lk_prod"`
|
||||
|
||||
// FXSource — откуда брать курсы валют для оценки валютных выпусков:
|
||||
// пусто/"cbr" — Банк России (открытый XML, без ключа), "accounting" — курс
|
||||
// учётной системы (OD_VALUES_RATES), если сервер стоит без выхода в интернет.
|
||||
FXSource string `json:"lk_fx_source,omitempty"`
|
||||
// SnapshotMode — ежедневный срез стоимости портфелей (из него кабинет рисует график):
|
||||
// пусто — автоматически (ведём, если настроен экспорт QUIK), "on" — всегда, "off" — не вести.
|
||||
// SnapshotAt — время снятия среза, «ЧЧ:ММ» (пусто = 20:30, после закрытия торгов).
|
||||
SnapshotMode string `json:"lk_snapshot_mode,omitempty"`
|
||||
SnapshotAt string `json:"lk_snapshot_at,omitempty"`
|
||||
|
||||
// EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка,
|
||||
// статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj.
|
||||
// Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно.
|
||||
|
||||
@@ -291,6 +291,8 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
go superviseLoop(ctx, "lk-emulator", s.rc, s.consumeEmulatedDecisions)
|
||||
// Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК.
|
||||
go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop)
|
||||
// Ежедневный срез стоимости портфелей: из него кабинет рисует график.
|
||||
go superviseLoop(ctx, "lk-snapshot", s.rc, s.lkSnapshotLoop)
|
||||
|
||||
// Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на
|
||||
// хостинге и обслуживает запросы клиентов через исходящее соединение.
|
||||
|
||||
@@ -92,6 +92,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave)
|
||||
mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload)
|
||||
mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave)
|
||||
mux.HandleFunc("/admin/setup/lk/data", h.lkDataSave)
|
||||
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
|
||||
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
|
||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||
@@ -322,6 +323,7 @@ type SetupData struct {
|
||||
LKUsers []LKUser // учётные записи кабинета
|
||||
LKUsersError string
|
||||
LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра
|
||||
LKData LKDataView // курсы валют и ежедневный срез стоимости
|
||||
Versions SystemVersions // версии компонентов bj и внешних систем
|
||||
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
|
||||
Features []LicenseFeatureView // признаки лицензии для карточки
|
||||
@@ -388,6 +390,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
||||
QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"),
|
||||
LKAgent: lkAgentStatus(s),
|
||||
LKPreview: lkPreviewStatus(h.rc, req),
|
||||
LKData: lkDataView(h.rc),
|
||||
LKKeyMask: lkKeyForPage(s.LK.APIKey),
|
||||
Versions: systemVersions(h.rc),
|
||||
Modules: modulesAllowed(h.rc),
|
||||
|
||||
@@ -695,6 +695,44 @@ function hbBind(inp,box,list,onPick){
|
||||
<p class="muted" style="font-size:12px;margin-top:8px">Вход — по учётным записям из карточки «Пользователи кабинета» ниже; сотрудник видит список клиентов, клиент — свой портфель. Данные те же, что получит кабинет на хостинге.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-lk-data">
|
||||
<h2><span class="dot {{if .LKData.SnapshotOn}}ok{{else}}warn{{end}}"></span>Данные кабинета: курсы и история</h2>
|
||||
<p class="muted">Чем кабинет считает стоимость портфеля. Курс нужен для выпусков с валютным номиналом (замещающие и еврооблигации): цена по ним — процент от номинала в долларах, юанях, евро. История стоимости накапливается только у нас: ни торговая система, ни брокерский учёт её не хранят.</p>
|
||||
|
||||
<form method="post" action="/admin/setup/lk/data" style="display:grid;gap:14px;max-width:780px;margin-top:12px">
|
||||
<input type="hidden" name="section" value="sec-lk-data">
|
||||
|
||||
<div class="fld">
|
||||
<label for="lk_fx_source">Курсы валют</label>
|
||||
<select id="lk_fx_source" name="fx_source">
|
||||
<option value="cbr" {{if ne .LKData.FXSource "accounting"}}selected{{end}}>Банк России — официальный курс, открытый канал без ключа</option>
|
||||
<option value="accounting" {{if eq .LKData.FXSource "accounting"}}selected{{end}}>Учётная система — курс из OD_VALUES_RATES (DEPO / SPECTRE)</option>
|
||||
</select>
|
||||
<span class="muted">{{if .LKData.FXAt}}Последний известный комплект: {{.LKData.FXAt}}, валют {{.LKData.FXCount}}.{{else}}Курсы ещё не загружались.{{end}} Полученные курсы сохраняются в базе bj по дням, поэтому при потере связи кабинет продолжит считать по последнему известному, а не покажет «нет курса». Курс учётной системы в нашей копии отстаёт на месяцы — выбирайте его только на сервере без выхода в интернет.{{with .LKData.FXNote}} {{.}}{{end}}</span>
|
||||
</div>
|
||||
|
||||
<div class="fld">
|
||||
<label>Ежедневный срез стоимости портфелей</label>
|
||||
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
|
||||
<select name="snapshot_mode" style="width:auto;min-width:320px">
|
||||
<option value="" {{if eq .LKData.SnapshotMode ""}}selected{{end}}>Автоматически — ведём, если настроен экспорт QUIK{{if .LKData.SnapshotOn}} (сейчас ведём){{else}} (сейчас нет: экспорт не настроен){{end}}</option>
|
||||
<option value="on" {{if eq .LKData.SnapshotMode "on"}}selected{{end}}>Всегда вести</option>
|
||||
<option value="off" {{if eq .LKData.SnapshotMode "off"}}selected{{end}}>Не вести</option>
|
||||
</select>
|
||||
<label style="display:flex;gap:7px;align-items:center;font-weight:400;text-transform:none;letter-spacing:0">время
|
||||
<input type="text" name="snapshot_at" value="{{.LKData.SnapshotAt}}" style="width:90px" placeholder="20:30"></label>
|
||||
</div>
|
||||
<span class="muted">{{if .LKData.Snapshots}}Накоплено дней: {{.LKData.Snapshots}}; последний срез {{.LKData.SnapLast}} — договоров {{.LKData.SnapRows}}.{{else}}Срезов пока нет: график в кабинете появится после первых двух дней.{{end}} Срез считает стоимость каждого портфеля по экспорту QUIK один раз в день, после закрытия торгов. Задним числом историю восстановить нельзя, поэтому включать лучше сразу.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
<button class="btn btn-secondary" type="submit" name="op" value="fx_now">Обновить курсы сейчас</button>
|
||||
<button class="btn btn-secondary" type="submit" name="op" value="snapshot_now">Снять срез сейчас</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-lk-users">
|
||||
<h2><span class="dot {{if .LKUsers}}ok{{else}}warn{{end}}"></span>Пользователи кабинета</h2>
|
||||
<p class="muted">Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. <b>Письма не отправляются</b>: почта только хранится, пароль сообщите лично.</p>
|
||||
|
||||
Reference in New Issue
Block a user