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

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