Кабинет: курсы ЦБ, история стоимости портфеля, быстрые операции, контакты в профиле
Курсы валют. Оценивать замещающие и еврооблигации по курсу из копии учётной системы было нельзя: в 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:
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user