diff --git a/modules/lk/lk.go b/modules/lk/lk.go
index 28cab86..2d54e8c 100644
--- a/modules/lk/lk.go
+++ b/modules/lk/lk.go
@@ -14,6 +14,7 @@ import (
"log"
"net/http"
"net/url"
+ "sort"
"strconv"
"strings"
"time"
@@ -119,6 +120,7 @@ type position struct {
ISIN string `json:"isin"`
SecurityCode string `json:"security_code"`
SecurityName string `json:"security_name"`
+ AccountID string `json:"account_id"`
Quantity float64 `json:"quantity"`
Blocked float64 `json:"blocked"`
Available float64 `json:"available"`
@@ -137,6 +139,56 @@ type claimRow struct {
Reason string `json:"reason"`
}
+// portfolioView — сводка реестра для доски: сколько выпусков всего, свободных и с ограничением.
+func portfolioView(pos []position) (total, free, blocked int, accounts []string) {
+ seen := map[string]bool{}
+ for _, p := range pos {
+ total++
+ switch {
+ case p.Blocked > 0 || !p.Transferable:
+ blocked++
+ default:
+ free++
+ }
+ if p.AccountID != "" && !seen[p.AccountID] {
+ seen[p.AccountID] = true
+ accounts = append(accounts, p.AccountID)
+ }
+ }
+ sort.Strings(accounts)
+ return
+}
+
+// claimsView — сколько поручений в работе и сколько завершено.
+func claimsView(rows []claimRow) (active, done int) {
+ for _, c := range rows {
+ switch c.Status {
+ case "done":
+ done++
+ case "rejected", "declined", "error":
+ default:
+ active++
+ }
+ }
+ return
+}
+
+// portfolioResp — ответ /api/lk/portfolio.
+type portfolioResp struct {
+ Contract string `json:"contract"`
+ FullName string `json:"full_name"`
+ Positions []position `json:"positions"`
+ Source string `json:"source"`
+ ReadOnly bool `json:"read_only"`
+}
+
+// portfolio — реестр клиента из ядра.
+func (s *Server) portfolio(r *http.Request, sess string) (portfolioResp, error) {
+ var out portfolioResp
+ err := s.bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out)
+ return out, err
+}
+
// ---- страницы ----
// render — страница; признаки режима (админ / просмотр кабинета клиента) — из кук этого запроса.
@@ -238,7 +290,7 @@ func (s *Server) Handler() http.Handler {
http.Redirect(w, r, "/login", http.StatusSeeOther)
})
- // Портфель
+ // Табло — обзор: реестр и последние поручения на одной странице.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
@@ -249,26 +301,88 @@ func (s *Server) Handler() http.Handler {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
- var out struct {
- Contract string `json:"contract"`
- FullName string `json:"full_name"`
- Positions []position `json:"positions"`
- Source string `json:"source"`
- ReadOnly bool `json:"read_only"`
- }
- if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
- s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
+ pf, err := s.portfolio(r, sess)
+ if err != nil {
+ s.render(w, r, "board", map[string]any{"Nav": "b", "Error": err.Error()})
return
}
- s.render(w, r, "portfolio", map[string]any{
- "Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
- "Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
- "Source": out.Source, "ReadOnly": out.ReadOnly,
+ total, free, blocked, _ := portfolioView(pf.Positions)
+ var cl struct {
+ Claims []claimRow `json:"claims"`
+ }
+ _ = bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &cl)
+ active, _ := claimsView(cl.Claims)
+ pos := pf.Positions
+ if len(pos) > 5 {
+ pos = pos[:5]
+ }
+ claims := cl.Claims
+ if len(claims) > 5 {
+ claims = claims[:5]
+ }
+ s.render(w, r, "board", map[string]any{
+ "Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName,
+ "Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"),
+ "Total": total, "Free": free, "Blocked": blocked, "Active": active,
})
})
- // Заявка на перевод
+ // Реестр бумаг
+ mux.HandleFunc("/portfolio", func(w http.ResponseWriter, r *http.Request) {
+ sess := sessionW(w, r)
+ if sess == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ pf, err := s.portfolio(r, sess)
+ if err != nil {
+ s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
+ return
+ }
+ total, free, blocked, _ := portfolioView(pf.Positions)
+ updated := ""
+ if len(pf.Positions) > 0 {
+ updated = pf.Positions[0].UpdatedAt
+ }
+ s.render(w, r, "portfolio", map[string]any{
+ "Nav": "p", "Contract": pf.Contract, "FullName": pf.FullName,
+ "Positions": pf.Positions, "Flash": r.URL.Query().Get("m"),
+ "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated,
+ "Total": total, "Free": free, "Blocked": blocked,
+ })
+ })
+
+ // Профиль: договор, счета депо, доступ
+ mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
+ sess := sessionW(w, r)
+ if sess == "" {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ pf, err := s.portfolio(r, sess)
+ if err != nil {
+ s.render(w, r, "profile", map[string]any{"Nav": "u", "Error": err.Error()})
+ return
+ }
+ _, _, _, accounts := portfolioView(pf.Positions)
+ login := ""
+ if c, err := r.Cookie("bj_lk_as"); err == nil {
+ login = c.Value
+ }
+ if login == "" {
+ login = pf.Contract
+ }
+ 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,
+ })
+ })
+
+ // Поручение депозитарию (сейчас — перевод бумаг в другой депозитарий)
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/order", http.StatusMovedPermanently)
+ })
+ mux.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -280,10 +394,11 @@ func (s *Server) Handler() http.Handler {
FullName string `json:"full_name"`
}
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
- s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
+ s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
- s.render(w, r, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
+ s.render(w, r, "transfer", map[string]any{"Nav": "c", "Positions": out.Positions, "FullName": out.FullName,
+ "PickISIN": r.URL.Query().Get("isin"), "Today": time.Now().Format("02.01.2006")})
return
}
_ = r.ParseForm()
@@ -300,7 +415,7 @@ func (s *Server) Handler() http.Handler {
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
}
if len(secs) == 0 {
- s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
+ s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": "Укажите количество хотя бы по одной бумаге", "Today": time.Now().Format("02.01.2006")})
return
}
var res struct {
@@ -315,14 +430,17 @@ func (s *Server) Handler() http.Handler {
"securities": secs,
}, &res)
if err != nil {
- s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
+ s.render(w, r, "transfer", map[string]any{"Nav": "c", "Error": err.Error(), "Today": time.Now().Format("02.01.2006")})
return
}
- http.Redirect(w, r, "/claims?m="+url.QueryEscape(res.Message), http.StatusSeeOther)
+ http.Redirect(w, r, "/orders?m="+url.QueryEscape(res.Message), http.StatusSeeOther)
})
- // Мои заявки
+ // Поручения — журнал всех видов
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/orders", http.StatusMovedPermanently)
+ })
+ mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -335,7 +453,9 @@ func (s *Server) Handler() http.Handler {
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
return
}
- s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
+ active, done := claimsView(out.Claims)
+ s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m"),
+ "Total": len(out.Claims), "Active": active, "Done": done})
})
// Администратор: список клиентов
diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go
index daa96c6..c2463cf 100644
--- a/modules/lk/lk_test.go
+++ b/modules/lk/lk_test.go
@@ -28,7 +28,18 @@ 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", "positions": []any{}})
+ _ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "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"},
+ map[string]any{"isin": "US0378331005", "security_name": "Apple Inc.", "account_id": "В000000044", "quantity": 12, "blocked": 0, "available": 0, "transferable": false, "note": "иностранная бумага — перевод через M2M недоступен"},
+ }})
+ }
+ })
+ mux.HandleFunc("/api/lk/claims", 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{"claims": []any{
+ map[string]any{"guid": "41523F7F-1111", "status": "sent", "status_ru": "Отправлено в НРД", "created_at": "16.09.2026", "securities": 1},
+ map[string]any{"guid": "D744A410-2222", "status": "done", "status_ru": "Завершена", "created_at": "09.09.2026", "securities": 2},
+ }})
}
})
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
@@ -123,3 +134,59 @@ func TestNotConfigured(t *testing.T) {
}
}
}
+
+// Страницы кабинета в оформлении «Табло»: обзор, реестр, поручения, профиль и бланк поручения.
+func TestBoardPages(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{"Бумаг в реестре", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
+ if !strings.Contains(board, want) {
+ t.Errorf("на табло нет %q", want)
+ }
+ }
+ reg := get("/portfolio")
+ for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
+ if !strings.Contains(reg, want) {
+ t.Errorf("в реестре нет %q", want)
+ }
+ }
+ orders := get("/orders")
+ if !strings.Contains(orders, "Поручения депозитарию") || !strings.Contains(orders, "Перевод в другой депозитарий") {
+ t.Errorf("журнал поручений: %s", orders)
+ }
+ order := get("/order?isin=RU0009029540")
+ for _, want := range []string{"Поручение депозитарию", "Вид поручения", "ИНН принимающего депозитария", `value="RU0009029540" id="cRU0009029540" checked`} {
+ if !strings.Contains(order, want) {
+ t.Errorf("в бланке поручения нет %q", want)
+ }
+ }
+ prof := get("/profile")
+ if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
+ t.Errorf("профиль: %s", prof)
+ }
+ // старые адреса продолжают работать
+ for path, to := range map[string]string{"/transfer": "/order", "/claims": "/orders"} {
+ 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.StatusMovedPermanently || rec.Header().Get("Location") != to {
+ t.Errorf("%s → %s: код %d, Location %q", path, to, rec.Code, rec.Header().Get("Location"))
+ }
+ }
+}
diff --git a/modules/lk/templates.go b/modules/lk/templates.go
index 1a598cf..f7937a4 100644
--- a/modules/lk/templates.go
+++ b/modules/lk/templates.go
@@ -2,310 +2,366 @@ package lk
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
+//
+// Визуальный мир — «Табло» (см. DESIGN.md, раздел «Личный кабинет клиента»):
+// графитовая доска состояния сверху, светлая бумага реестра, янтарь — «в пути»
+// и первичное действие, все величины моноширинные табличные. Шрифты грузятся с
+// Google Fonts, при недоступности сети подхватываются системные аналоги.
const pageTemplates = `
{{define "head"}}
{{.Title}}
+
+
{{end}}
-{{define "nav"}}
-{{if .AsClient}}{{end}}{{end}}
+{{define "mark"}} {{end}}
+
+{{define "nav"}}
+{{if .AsClient}}
+
+
Вы в кабинете клиента {{.AsClient}} от имени сотрудника. Поручение можно подготовить, подписывает его клиент.
+
вернуться к списку клиентов {{end}}{{end}}
{{define "login"}}{{template "head" .}}
-
-
-
{{.Title}}
-
Войдите, чтобы посмотреть свои ценные бумаги и подать заявку на перевод к другому брокеру.
- {{with .Flash}}
{{.}}
{{end}}
-
{{end}}
-{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
-
- Мои ценные бумаги
- {{if .Source}}Источник данных: {{.Source}}{{if .Contract}} · договор {{.Contract}}{{end}}
{{end}}
- {{with .Flash}}{{.}}
{{end}}
- {{with .Error}}{{.}}
{{end}}
-
- {{if .Positions}}
-
- Бумага ISIN Всего Заблокировано Доступно к переводу
-
- {{range .Positions}}
-
- {{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}
- {{.ISIN}}
- {{fmtnum .Quantity}}
- {{if .Blocked}}{{fmtnum .Blocked}}{{else}}—{{end}}
- {{if .Transferable}}{{fmtnum .Available}} {{else}}не переводится {{end}}
-
- {{end}}
-
-
-
Через сервис MOEX МОСТ переводятся только российские ценные бумаги (ISIN начинается с RU). Иностранные бумаги показаны для полноты портфеля, но заявку по ним подать нельзя.
- {{if .ReadOnly}}
-
📋 Данные загружены из депозитарного учёта. Подача заявок на перевод для этого договора пока недоступна — раздел работает в режиме просмотра.
- {{else}}
-
Перевести бумаги другому брокеру
- {{end}}
- {{else}}
-
Бумаг на счёте не найдено. Если вы считаете, что это ошибка — обратитесь к вашему брокеру.
- {{end}}
+{{define "board"}}{{template "head" .}}{{template "nav" .}}
+
+
+
Бумаг в реестре
+
{{.Total}}
+
{{with .FullName}}{{.}} · {{end}}договор {{.Contract}}
+
+ Свободно к переводу {{.Free}}
+ С ограничением {{.Blocked}}
+ Поручений в работе {{.Active}}
+
+
+
+ {{if .Flash}}{{.Flash}}
{{end}}
+ {{if .Error}}{{.Error}}
{{end}}
+
+
+ {{if .Claims}}
+ Номер Дата Вид поручения Бумаг Состояние
+ {{range .Claims}}
+ {{shortid .GUID}}
+ {{.CreatedAt}}
+ Перевод в другой депозитарий
+ {{.Securit}}
+ {{.StatusRu}} {{with .Reason}}{{.}}
{{end}}
+ {{end}}
+ {{else}}Поручений пока нет. Нажмите «Новое поручение», чтобы перевести бумаги в другой депозитарий.
{{end}}
+
+
+
+
+ {{if .Positions}}
+ Выпуск Счёт депо Количество Свободно Состояние
+ {{range .Positions}}
+ {{.SecurityName}} {{.ISIN}}
+ {{if .AccountID}}{{.AccountID}}{{else}}—{{end}}
+ {{fmtnum .Quantity}}
+ {{fmtnum .Available}}
+ {{if not .Transferable}}не переводится {{else if gt .Blocked 0.0}}{{fmtnum .Blocked}} с ограничением {{else}}свободно {{end}}
+ {{end}}
+ {{else}}Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.
{{end}}
+
+