Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле

Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной
системы было нельзя: в 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:
zuevav
2026-09-18 19:25:18 +03:00
parent 683f516d07
commit 510a34b7bc
17 changed files with 1026 additions and 96 deletions
+187
View File
@@ -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
}
+29
View File
@@ -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
View File
@@ -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
}
+117
View File
@@ -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)
}
+39 -2
View File
@@ -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()
}
+1 -1
View File
@@ -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 {
+226
View File
@@ -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
}
+3 -1
View File
@@ -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)
+8
View File
@@ -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")
}
+1 -1
View File
@@ -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, `
+10
View File
@@ -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.
// Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно.
+2
View File
@@ -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)
// Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на
// хостинге и обслуживает запросы клиентов через исходящее соединение.
+3
View File
@@ -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>
+133 -27
View File
@@ -72,6 +72,13 @@ func New(cfg Config) (*Server, error) {
return strconv.FormatFloat(f, 'f', -1, 64)
},
"fmtmoney": fmtMoney,
// доля координаты X в ширине холста — для подписей дат под графиком
"pctOf": func(x, w float64) float64 {
if w == 0 {
return 0
}
return x / w * 100
},
// Русское число: 1 договор, 2 договора, 5 договоров.
"plural": func(n int, one, few, many string) string {
w := many
@@ -400,6 +407,9 @@ type portfolioResp struct {
ReadOnly bool `json:"read_only"`
TotalValue float64 `json:"total_value"`
Priced int `json:"priced"`
Email string `json:"email"`
Phone string `json:"phone"`
LastLogin string `json:"last_login"`
}
// operationRow — движение по счёту депо.
@@ -455,6 +465,110 @@ type lkUpcoming struct {
Percent float64 `json:"percent"`
}
// lkPoint — точка истории стоимости портфеля (ответ /api/lk/history).
type lkPoint struct {
Day string `json:"day"`
Value float64 `json:"value"`
Cash float64 `json:"cash"`
Total float64 `json:"total"`
Positions int `json:"positions"`
}
// lkChart — готовый график для вставки в страницу: считаем его на сервере и
// отдаём координатами, потому что скриптов в кабинете нет (строгая CSP).
type lkChart struct {
Line, Area string // пути SVG
W, H float64 // размеры холста
Min, Max float64 // границы шкалы
First, Last float64 // стоимость в начале и в конце периода
Change float64 // изменение за период, ₽
ChangePct float64 // изменение за период, %
FromDay string // первая дата периода
ToDay string // последняя дата
Points int
GridY []float64 // горизонтальные линии (y в координатах холста)
GridLabel []string // подписи к ним
Marks []lkMark // подписи дат под осью
}
// lkMark — подпись даты на оси времени.
type lkMark struct {
X float64
Text string
}
// portfolioChart — линия стоимости портфеля по ежедневным срезам.
func portfolioChart(points []lkPoint) *lkChart {
if len(points) < 2 {
return nil
}
const w, h, pad = 980.0, 190.0, 10.0
lo, hi := points[0].Total, points[0].Total
for _, p := range points {
if p.Total < lo {
lo = p.Total
}
if p.Total > hi {
hi = p.Total
}
}
// Ось не от нуля: иначе движение на 1–2 % не видно. Запас сверху и снизу —
// чтобы линия не липла к краю.
span := hi - lo
if span <= 0 {
span = hi * 0.02
if span <= 0 {
span = 1
}
}
lo, hi = lo-span*0.15, hi+span*0.15
x := func(i int) float64 { return pad + (w-2*pad)*float64(i)/float64(len(points)-1) }
y := func(v float64) float64 { return pad + (h-2*pad)*(1-(v-lo)/(hi-lo)) }
var line, area strings.Builder
for i, p := range points {
cmd := "L"
if i == 0 {
cmd = "M"
}
fmt.Fprintf(&line, "%s%.1f %.1f ", cmd, x(i), y(p.Total))
}
area.WriteString(line.String())
fmt.Fprintf(&area, "L%.1f %.1f L%.1f %.1f Z", x(len(points)-1), h, x(0), h)
c := &lkChart{Line: strings.TrimSpace(line.String()), Area: area.String(), W: w, H: h,
Min: lo, Max: hi, First: points[0].Total, Last: points[len(points)-1].Total,
FromDay: ruDateFull(points[0].Day), ToDay: ruDateFull(points[len(points)-1].Day), Points: len(points)}
c.Change = c.Last - c.First
if c.First != 0 {
c.ChangePct = c.Change / c.First * 100
}
for _, frac := range []float64{0, 0.5, 1} {
v := lo + (hi-lo)*frac
c.GridY = append(c.GridY, y(v))
c.GridLabel = append(c.GridLabel, fmtMoney(v))
}
// Подписи дат: начало, середина, конец — больше на узкой карточке не влезает.
for _, i := range []int{0, len(points) / 2, len(points) - 1} {
c.Marks = append(c.Marks, lkMark{X: x(i), Text: ruDate(points[i].Day)})
}
return c
}
// ruDateFull — 2026-09-18 → 18.09.2026.
func ruDateFull(iso string) string {
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01.2006")
}
return iso
}
// ruDate — 2026-09-18 → 18.09.
func ruDate(iso string) string {
if t, err := time.Parse("2006-01-02", iso); err == nil {
return t.Format("02.01")
}
return iso
}
// lkSegment — доля вида активов в портфеле для полосы структуры.
type lkSegment struct {
Kind string
@@ -810,6 +924,20 @@ func (s *Server) buildHandler() http.Handler {
upTotal += u.Total
}
data["UpcomingTotal"] = upTotal
// График стоимости — только для «Онлайн»: в архиве он был бы историей
// срезов, а не тем, что показано на экране.
if view != "archive" {
var hist struct {
Points []lkPoint `json:"points"`
On bool `json:"on"`
At string `json:"at"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/history?days=180", sess, nil, &hist); err == nil {
data["Chart"] = portfolioChart(hist.Points)
data["ChartPoints"] = len(hist.Points)
data["ChartOn"] = hist.On
}
}
s.render(w, r, "portfolio", data)
})
@@ -892,39 +1020,16 @@ func (s *Server) buildHandler() http.Handler {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost {
_ = r.ParseForm()
var res struct {
Message string `json:"message"`
}
err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{
"kind": r.FormValue("kind"),
"from": r.FormValue("from"),
"to": r.FormValue("to"),
"account": r.FormValue("account"),
"comment": strings.TrimSpace(r.FormValue("comment")),
}, &res)
if err != nil {
setFlashCookie(w, r, err.Error())
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
setFlashCookie(w, r, res.Message)
http.Redirect(w, r, "/documents", http.StatusSeeOther)
return
}
var out struct {
Ready []documentRow `json:"ready"`
Requests []documentRow `json:"requests"`
Kinds []string `json:"kinds"`
Ready []documentRow `json:"ready"`
}
if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil {
s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()})
return
}
s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests,
"Kinds": out.Kinds, // форма заказа в клиентском виде скрыта; данные оставлены для API
"From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")})
// Заказ документов через кабинет убран: выписки готовит депозитарий,
// клиенту показываем только готовое.
s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready})
})
// Профиль: договор, счета депо, доступ
@@ -950,6 +1055,7 @@ func (s *Server) buildHandler() http.Handler {
s.render(w, r, "profile", map[string]any{
"Nav": "u", "Contract": pf.Contract, "FullName": pf.FullName,
"AccountList": accounts, "Accounts": len(accounts), "ReadOnly": pf.ReadOnly, "Login": login,
"Email": pf.Email, "Phone": pf.Phone, "LastLogin": pf.LastLogin,
})
})
+54 -1
View File
@@ -33,7 +33,7 @@ func fakeBJ(t *testing.T) *httptest.Server {
})
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "positions": []any{
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "email": "client@example.org", "phone": "+7 900 000-00-00", "last_login": "17.09.2026 21:58", "positions": []any{
map[string]any{"isin": "RU0009029540", "security_name": "Сбербанк, ао", "account_id": "ВТНКЦ00000001", "quantity": 400, "blocked": 100, "available": 300, "transferable": true, "updated_at": "17.09.2026 21:58", "price": 275.67, "value": 110268, "change_pct": -1.53},
map[string]any{"isin": "US0378331005", "security_name": "Apple Inc.", "account_id": "В000000044", "quantity": 12, "blocked": 0, "available": 0, "transferable": false, "note": "иностранная бумага — перевод через M2M недоступен"},
}})
@@ -47,6 +47,15 @@ func fakeBJ(t *testing.T) *httptest.Server {
}})
}
})
mux.HandleFunc("/api/lk/history", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"on": true, "at": "20:30", "points": []any{
map[string]any{"day": "2026-09-16", "value": 100000.0, "cash": 200.0, "total": 100200.0},
map[string]any{"day": "2026-09-17", "value": 104000.0, "cash": 200.0, "total": 104200.0},
map[string]any{"day": "2026-09-18", "value": 110068.0, "cash": 200.0, "total": 110268.0},
}})
}
})
mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) || r.Header.Get("X-BJ-Session") != "sess-1" {
return
@@ -561,3 +570,47 @@ func TestClientsSearch(t *testing.T) {
t.Errorf("пустой результат не объяснён: %s", empty)
}
}
// График стоимости и контакты в профиле.
func TestChartAndProfile(t *testing.T) {
bj := fakeBJ(t)
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key", Title: "Кабинет"})
if err != nil {
t.Fatal(err)
}
h := s.Handler()
get := func(path string) string {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: код %d", path, rec.Code)
}
return rec.Body.String()
}
board := get("/")
for _, want := range []string{"Стоимость портфеля", "<svg viewBox=", "срезов: 3", "16.09"} {
if !strings.Contains(board, want) {
t.Errorf("на графике нет %q", want)
}
}
// В архиве график не показываем: там стоимость на выбранную дату.
if strings.Contains(get("/?view=archive&date=2025-12-31"), `class="sec chart"`) {
t.Error("в архиве график лишний")
}
prof := get("/profile")
for _, want := range []string{"Контакты в анкете", "client@example.org", "900 000-00-00", "Прошлый вход"} {
if !strings.Contains(prof, want) {
t.Errorf("в профиле нет %q", want)
}
}
// Заказ документов убран: формы и обработчика больше нет.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/documents", nil)
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
h.ServeHTTP(rec, req)
if rec.Code == http.StatusSeeOther {
t.Error("POST /documents всё ещё принимает заказ документа")
}
}
+29
View File
@@ -85,6 +85,13 @@ input,select,textarea{width:100%;min-height:44px;padding:10px 12px;border:1px so
input:focus,select:focus,textarea:focus{outline:2px solid var(--acc);outline-offset:1px}
input[type=checkbox]{width:20px;min-height:20px;height:20px}
/* поиск клиентов: поле с подсказками браузера (datalist) + кнопки в строку */
.chart svg{width:100%;height:190px;display:block}
.chart .g{stroke:var(--line);stroke-width:1}
.chart .a{fill:var(--amber-bg);opacity:.55}
.chart .l{fill:none;stroke:var(--amber);stroke-width:2;vector-effect:non-scaling-stroke}
.chart .axis{position:relative;height:16px;margin-top:2px;font-size:11px;color:var(--mut)}
.chart .axis span{position:absolute;transform:translateX(-50%);white-space:nowrap}
.chart .scale{display:flex;justify-content:space-between;font-size:11px;color:var(--mut);margin-top:2px}
.cfind{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;max-width:720px}
.cfind .btn{flex:none}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:46px;padding:0 18px;border:1px solid var(--line-2);border-radius:3px;background:#fff;color:var(--ink);font:inherit;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none}
@@ -185,6 +192,19 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<span class="mut" style="margin-left:auto;font-size:12.5px">{{if eq .View "archive"}}Портфель по брокерскому учёту на выбранную дату{{else if eq $p.Source "quik"}}Позиции и деньги в торговой системе, цены биржи{{else}}Портфель по брокерскому учёту{{end}}</span>
</div>
{{with .Chart}}<section class="sec chart" style="margin-bottom:18px">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Стоимость портфеля</h2>
<span class="mut" style="font-size:12.5px">{{.FromDay}} — {{.ToDay}} · <span class="{{if lt .Change 0.0}}neg{{else if gt .Change 0.0}}pos{{end}}">{{fmtsigned .Change}} ₽ · {{fmtpct .ChangePct}}</span></span></div>
<svg viewBox="0 0 {{printf "%.0f" .W}} {{printf "%.0f" .H}}" preserveAspectRatio="none" role="img" aria-label="График стоимости портфеля">
{{range .GridY}}<line x1="0" y1="{{printf "%.1f" .}}" x2="{{printf "%.0f" $.Chart.W}}" y2="{{printf "%.1f" .}}" class="g"></line>{{end}}
<path d="{{.Area}}" class="a"></path>
<path d="{{.Line}}" class="l"></path>
</svg>
<div class="axis n">{{range .Marks}}<span style="left:{{printf "%.2f" (pctOf .X $.Chart.W)}}%">{{.Text}}</span>{{end}}</div>
<div class="scale n"><span>шкала {{fmtmoney .Min}} — {{fmtmoney .Max}} ₽</span><span>срезов: {{.Points}}</span></div>
</section>
{{else}}{{if and (ne .View "archive") .ChartOn}}<p class="mut" style="margin:-4px 0 16px;font-size:12.5px">График стоимости появится, когда накопятся ежедневные срезы: сейчас их {{.ChartPoints}}.</p>{{end}}{{end}}
{{if .Structure}}<section class="sec" style="margin-bottom:18px">
<div class="sec-h"><h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Структура портфеля</h2></div>
<div class="sbar">{{range .Structure}}<span style="width:{{printf "%.2f" .Share}}%;background:{{.Color}}" title="{{.Kind}}: {{fmtmoney .Value}} ₽"></span>{{end}}</div>
@@ -434,6 +454,15 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</div>
<p class="mut" style="margin-top:14px">Контакты, паспортные данные и реквизиты для выплат меняет депозитарий: обратитесь к своему менеджеру, изменения появятся в кабинете после внесения в анкету.</p>
</section>
<section class="sec">
<h2 class="lbl" style="font-size:12px;letter-spacing:.14em">Контакты в анкете</h2>
<div class="rows">
<div class="row"><span>Электронная почта</span><span class="n">{{if .Email}}{{.Email}}{{else}}не указана{{end}}</span></div>
<div class="row"><span>Телефон</span><span class="n">{{if .Phone}}{{.Phone}}{{else}}не указан{{end}}</span></div>
{{if .LastLogin}}<div class="row"><span>Прошлый вход</span><span class="n">{{.LastLogin}}</span></div>{{end}}
</div>
<p class="mut" style="margin-top:14px">Так с вами свяжется депозитарий. Если данные устарели — сообщите менеджеру: из кабинета мы их не меняем.</p>
</section>
</div>
</main></body></html>{{end}}