From f057b89d2e33b28dc340f3fdd66fb37c3dbe8bee Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 17 Sep 2026 22:51:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82:=20=D0=BE=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=C2=AB=D0=A2=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=C2=BB,=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8B=20=D0=A2=D0=B0=D0=B1=D0=BB=D0=BE/=D0=A0=D0=B5?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=80/=D0=9F=D0=BE=D1=80=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F/=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C,?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D0=B2=D0=B8=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- modules/lk/lk.go | 164 +++++++++-- modules/lk/lk_test.go | 69 ++++- modules/lk/templates.go | 598 ++++++++++++++++++++++------------------ 3 files changed, 537 insertions(+), 294 deletions(-) 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"}}
{{.Title}} -{{if .IsAdmin}}Клиенты{{end}} -Мои бумаги -Перевести бумаги -Мои заявки -{{with .FullName}}{{.}}{{end}}{{with .Contract}} · договор {{.}}{{end}} Выйти -
-{{if .AsClient}}
- 👤 Вы просматриваете кабинет клиента {{.AsClient}} от имени администратора · - вернуться к списку клиентов
{{end}}{{end}} +{{define "mark"}}{{end}} + +{{define "nav"}}
+ {{template "mark" .}}{{.Title}} + + Новое поручение + {{with .FullName}}{{.}}{{end}}{{with .Contract}} · {{.}}{{end}} + Выход +
+{{if .AsClient}}
+ + Вы в кабинете клиента {{.AsClient}} от имени сотрудника. Поручение можно подготовить, подписывает его клиент. + вернуться к списку клиентов
{{end}}{{end}} {{define "login"}}{{template "head" .}} -