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

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