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

Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной
системы было нельзя: в 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
+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
}