From 4b18579956a29b4516fa666044a25b7a58df49e4 Mon Sep 17 00:00:00 2001 From: zuevav Date: Thu, 17 Sep 2026 23:11:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82:=20?= =?UTF-8?q?=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D1=87=D0=B5=D1=82=D0=B0=D0=BC=20=D0=B4=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B8=20=D0=B4=D0=BE=D1=85=D0=BE=D0=B4=D1=8B=20=D1=81?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B5=D1=80=D0=B6=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=BE=D0=BC=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=83=D1=87=D1=91=D1=82=D0=BD=D0=BE=D0=B9=20=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- internal/lkgateway/lkapi.go | 45 ++++ internal/lkgateway/lkhistory.go | 274 ++++++++++++++++++++++ internal/lkgateway/lkhistory_live_test.go | 42 ++++ internal/lkgateway/security_card.go | 4 +- modules/lk/lk.go | 91 +++++++ modules/lk/lk_test.go | 32 +++ modules/lk/templates.go | 65 +++++ 7 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 internal/lkgateway/lkhistory.go create mode 100644 internal/lkgateway/lkhistory_live_test.go diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 8c323f5..8615782 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -293,6 +293,51 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { lkJSON(w, http.StatusOK, map[string]any{"security": card}) }) + // Движения по счетам депо за период. + mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + q := r.URL.Query() + ops, err := lkOperations(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to"), 200) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить операции: " + err.Error()}) + return + } + lkJSON(w, http.StatusOK, map[string]any{"operations": ops, "from": q.Get("from"), "to": q.Get("to")}) + }) + + // Выплаты дохода и удержанный налог. + mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + q := r.URL.Query() + rows, tax, err := lkIncome(r.Context(), rc.Snapshot().Fancy, u.Contract, q.Get("from"), q.Get("to")) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": "не удалось получить доходы: " + err.Error()}) + return + } + var accrued, paid, taxRows float64 + for _, p := range rows { + accrued += p.Accrued + paid += p.Paid + taxRows += p.Tax + } + if tax == 0 { + tax = taxRows + } + lkJSON(w, http.StatusOK, map[string]any{"income": rows, "accrued": accrued, "paid": paid, "tax": tax}) + }) + mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return diff --git a/internal/lkgateway/lkhistory.go b/internal/lkgateway/lkhistory.go new file mode 100644 index 0000000..cc0ea39 --- /dev/null +++ b/internal/lkgateway/lkhistory.go @@ -0,0 +1,274 @@ +package lkgateway + +// lkhistory.go — история для кабинета клиента: движения по счетам депо и выплаты дохода +// с удержанным налогом. Источник — учётная система депозитария (DEPO): +// +// операции: OD_DOCS + U_OP_INV_ZACH / U_OP_INV_SPIS / U_OP_INV_PEREVOD (связь по DEPO = OD_CLIENTS.ID); +// доходы: U_OP_INF_INCOME + U_OP_INF_INCOME_DOL (связь по DEPO или INCOME_FACE); +// налог: OD_NDFL по лицу. +// +// Кабинет ничего не считает сам: суммы и статусы показываются как в учёте. + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// LKOperation — движение по счёту депо. +type LKOperation struct { + Date string `json:"date"` // дата регистрации, ДД.ММ.ГГГГ + DoneAt string `json:"done_at,omitempty"` // дата проведения + Kind string `json:"kind"` // зачисление | списание | перевод между счетами + Number string `json:"number,omitempty"` // номер документа депозитария + Account string `json:"account,omitempty"` // счёт депо + ISIN string `json:"isin,omitempty"` + Security string `json:"security,omitempty"` + Quantity float64 `json:"quantity"` // со знаком: списание отрицательное + State string `json:"state"` // исполнено | в обработке | отменено + Note string `json:"note,omitempty"` // комментарий или причина отказа +} + +// LKIncome — выплата дохода клиенту. +type LKIncome struct { + Date string `json:"date"` + Kind string `json:"kind"` // купон | дивиденды | доход + ISIN string `json:"isin,omitempty"` + Security string `json:"security,omitempty"` + Quantity float64 `json:"quantity,omitempty"` // бумаг в расчёте + Accrued float64 `json:"accrued"` // начислено + Tax float64 `json:"tax"` // удержан налог + Paid float64 `json:"paid"` // к выплате + State string `json:"state,omitempty"` + Account string `json:"account,omitempty"` +} + +// depoClientIDs — внутренние идентификаторы счетов депо клиента и его лицо в DEPO. +func depoClientIDs(ctx context.Context, s FancyMSSQLSettings, contract string) (ids []int64, face int64, accounts map[int64]string, err error) { + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, 0, nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + rows, err := conn.QueryContext(cctx, `SELECT DISTINCT c.ID, ISNULL(c.NUMBER,''), ISNULL(c.FACE,0) + FROM OD_CLIENTS c JOIN OD_CLIENTS_PORTS p ON p.CLIENT = c.ID + WHERE p.PORT_NUM = @p1 OR p.PORT_NUM LIKE @p2`, contract, contract+" %") + if err != nil { + return nil, 0, nil, err + } + defer rows.Close() + accounts = map[int64]string{} + for rows.Next() { + var id, f int64 + var num string + if rows.Scan(&id, &num, &f) != nil { + continue + } + ids = append(ids, id) + accounts[id] = num + if face == 0 { + face = f + } + } + return ids, face, accounts, rows.Err() +} + +// docStateRu — состояние документа депозитария словами. +func docStateRu(state sql.NullInt64, proc sql.NullTime) string { + switch { + case state.Valid && state.Int64 == 2: + return "исполнено" + case state.Valid && state.Int64 == 3: + return "отменено" + case proc.Valid: + return "исполнено" + } + return "в обработке" +} + +// inClause — список идентификаторов для IN (...) — значения свои, не от пользователя. +func inClause(ids []int64) string { + parts := make([]string, 0, len(ids)) + for _, id := range ids { + parts = append(parts, fmt.Sprint(id)) + } + return strings.Join(parts, ",") +} + +// parseRange — период запроса: пустые значения — последний год по сегодня. +func parseRange(from, to string) (time.Time, time.Time) { + fromT, err := time.Parse("2006-01-02", strings.TrimSpace(from)) + if err != nil { + fromT = time.Now().AddDate(-1, 0, 0) + } + toT, err := time.Parse("2006-01-02", strings.TrimSpace(to)) + if err != nil { + toT = time.Now() + } + return time.Date(fromT.Year(), fromT.Month(), fromT.Day(), 0, 0, 0, 0, time.UTC), + time.Date(toT.Year(), toT.Month(), toT.Day(), 0, 0, 0, 0, time.UTC) +} + +// lkOperations — движения по счетам депо клиента за период (from/to — ГГГГ-ММ-ДД, можно пустыми). +func lkOperations(ctx context.Context, s FancyMSSQLSettings, contract, from, to string, limit int) ([]LKOperation, error) { + if strings.TrimSpace(s.Server) == "" { + return nil, fmt.Errorf("учётная система депозитария не подключена") + } + ids, _, accounts, err := depoClientIDs(ctx, s, contract) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, nil + } + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if limit <= 0 || limit > 500 { + limit = 200 + } + fromT, toT := parseRange(from, to) + // Три вида операций в одном списке: зачисление, списание, перевод между счетами. + q := fmt.Sprintf(`SELECT TOP %d * FROM ( + SELECT d.R_DATE AS RD, d.PROC_DATE AS PD, ISNULL(d.NUM,'') AS NUM, d.STATE AS ST, N'зачисление' AS KIND, + z.DEPO AS ACC, ISNULL(v.ISIN,'') AS ISIN, ISNULL(v.NAME,'') AS SEC, CAST(ISNULL(z.AMOUNT,0) AS float) AS QTY, + LTRIM(RTRIM(ISNULL(z.COMMENT,'') + ' ' + ISNULL(z.REJECT_REASON,''))) AS NOTE + FROM U_OP_INV_ZACH z JOIN OD_DOCS d ON d.ID = z.DOC LEFT JOIN OD_VALUES v ON v.ID = z.SHARE + WHERE z.DEPO IN (%s) + UNION ALL + SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'списание', + p.DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(p.AMOUNT,0) AS float), + LTRIM(RTRIM(ISNULL(p.COMMENT,'') + ' ' + ISNULL(p.REJECT_REASON,''))) + FROM U_OP_INV_SPIS p JOIN OD_DOCS d ON d.ID = p.DOC LEFT JOIN OD_VALUES v ON v.ID = p.SHARE + WHERE p.DEPO IN (%s) + UNION ALL + SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: списание', + t.D_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), -CAST(ISNULL(t.AMOUNT,0) AS float), + LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,''))) + FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE + WHERE t.D_DEPO IN (%s) + UNION ALL + SELECT d.R_DATE, d.PROC_DATE, ISNULL(d.NUM,''), d.STATE, N'перевод: зачисление', + t.K_DEPO, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), CAST(ISNULL(t.AMOUNT,0) AS float), + LTRIM(RTRIM(ISNULL(t.COMMENT,'') + ' ' + ISNULL(t.REJECT_REASON,''))) + FROM U_OP_INV_PEREVOD t JOIN OD_DOCS d ON d.ID = t.DOC LEFT JOIN OD_VALUES v ON v.ID = t.SHARE + WHERE t.K_DEPO IN (%s)) x + WHERE x.RD >= @p1 AND x.RD < DATEADD(day, 1, @p2) + ORDER BY x.RD DESC, x.NUM DESC`, limit, inClause(ids), inClause(ids), inClause(ids), inClause(ids)) + rows, err := conn.QueryContext(cctx, q, fromT, toT) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKOperation + for rows.Next() { + var rd, pd sql.NullTime + var num, kind, isin, sec, note string + var st sql.NullInt64 + var acc int64 + var qty float64 + if rows.Scan(&rd, &pd, &num, &st, &kind, &acc, &isin, &sec, &qty, ¬e) != nil { + continue + } + op := LKOperation{Kind: kind, Number: num, ISIN: isin, Security: sec, Quantity: qty, + Account: accounts[acc], State: docStateRu(st, pd), Note: strings.TrimSpace(note)} + if rd.Valid { + op.Date = rd.Time.Format("02.01.2006") + } + if pd.Valid && pd.Time.Year() < 9000 { + op.DoneAt = pd.Time.Format("02.01.2006") + } + out = append(out, op) + } + return out, rows.Err() +} + +// incomeKindRu — вид дохода из учёта. +func incomeKindRu(kind sql.NullInt64) string { + switch { + case !kind.Valid: + return "доход" + case kind.Int64 == 1: + return "дивиденды" + case kind.Int64 == 2: + return "купон" + case kind.Int64 == 3: + return "погашение" + } + return "доход" +} + +// lkIncome — выплаты дохода клиенту за период и удержанный налог по данным учёта. +func lkIncome(ctx context.Context, s FancyMSSQLSettings, contract, from, to string) (rowsOut []LKIncome, taxTotal float64, err error) { + if strings.TrimSpace(s.Server) == "" { + return nil, 0, fmt.Errorf("учётная система депозитария не подключена") + } + ids, face, accounts, err := depoClientIDs(ctx, s, contract) + if err != nil { + return nil, 0, err + } + if len(ids) == 0 { + return nil, 0, nil + } + conn, err := fancyOpen(s, s.DepoDB()) + if err != nil { + return nil, 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + fromT, toT := parseRange(from, to) + q := fmt.Sprintf(`SELECT TOP 200 h.P_DATE, h.INCOME_KIND, ISNULL(v.ISIN,''), ISNULL(v.NAME,''), + CAST(ISNULL(l.AMOUNT,0) AS float), CAST(ISNULL(l.SUMMA_TOTAL,0) AS float), + CAST(ISNULL(l.TAX_HOLDED,0) AS float), CAST(ISNULL(l.TOTAL_TO_PAY,0) AS float), + ISNULL(l.STATUS,''), ISNULL(l.DEPO,0) + FROM U_OP_INF_INCOME_DOL l + JOIN OD_DOLS o ON o.ID = l.DOL + JOIN U_OP_INF_INCOME h ON h.DOC = o.DOC + LEFT JOIN OD_VALUES v ON v.ID = h.SHARE + WHERE (l.DEPO IN (%s) OR l.INCOME_FACE = @p3) + AND h.P_DATE >= @p1 AND h.P_DATE < DATEADD(day, 1, @p2) + ORDER BY h.P_DATE DESC`, inClause(ids)) + rows, err := conn.QueryContext(cctx, q, fromT, toT, face) + if err != nil { + return nil, 0, err + } + defer rows.Close() + for rows.Next() { + var pd sql.NullTime + var kind sql.NullInt64 + var isin, sec, status string + var qty, accrued, tax, paid float64 + var acc int64 + if rows.Scan(&pd, &kind, &isin, &sec, &qty, &accrued, &tax, &paid, &status, &acc) != nil { + continue + } + in := LKIncome{Kind: incomeKindRu(kind), ISIN: isin, Security: sec, Quantity: qty, + Accrued: accrued, Tax: tax, Paid: paid, Account: accounts[acc]} + if st := strings.TrimSpace(status); st != "" && st != "0" { // «0» — служебное значение учёта + in.State = st + } + if pd.Valid { + in.Date = pd.Time.Format("02.01.2006") + } + rowsOut = append(rowsOut, in) + } + if err := rows.Err(); err != nil { + return rowsOut, 0, err + } + // Удержанный налог по лицу за период — по журналу НДФЛ депозитария. + if face > 0 { + _ = conn.QueryRowContext(cctx, `SELECT CAST(ISNULL(SUM(CAST(SUMMA AS float)),0) AS float) FROM OD_NDFL + WHERE FACE = @p1 AND R_DATE >= @p2 AND R_DATE < DATEADD(day, 1, @p3)`, + sql.Named("p1", face), sql.Named("p2", fromT), sql.Named("p3", toT)).Scan(&taxTotal) + } + return rowsOut, taxTotal, nil +} diff --git a/internal/lkgateway/lkhistory_live_test.go b/internal/lkgateway/lkhistory_live_test.go new file mode 100644 index 0000000..2f29f9b --- /dev/null +++ b/internal/lkgateway/lkhistory_live_test.go @@ -0,0 +1,42 @@ +//go:build live + +package lkgateway + +import ( + "context" + "testing" +) + +// Движения и доходы клиента из учётной системы. +func TestLiveHistory(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + fs := rc.Snapshot().Fancy + for _, contract := range []string{"БР-2000", "БР-1529", "БР-859"} { + ops, err := lkOperations(context.Background(), fs, contract, "2024-01-01", "", 20) + if err != nil { + t.Fatalf("операции %s: %v", contract, err) + } + t.Logf("%s: операций %d", contract, len(ops)) + for i, o := range ops { + if i > 4 { + break + } + t.Logf(" %s %s %s %s %.0f %s %s", o.Date, o.Kind, o.Account, o.Security, o.Quantity, o.State, o.Note) + } + inc, tax, err := lkIncome(context.Background(), fs, contract, "2024-01-01", "") + if err != nil { + t.Fatalf("доходы %s: %v", contract, err) + } + t.Logf("%s: выплат %d, налог по журналу %.2f", contract, len(inc), tax) + for i, p := range inc { + if i > 4 { + break + } + t.Logf(" %s %s %s начислено %.2f налог %.2f к выплате %.2f %s", p.Date, p.Kind, p.Security, p.Accrued, p.Tax, p.Paid, p.State) + } + } +} diff --git a/internal/lkgateway/security_card.go b/internal/lkgateway/security_card.go index aa890dc..d1c17ee 100644 --- a/internal/lkgateway/security_card.go +++ b/internal/lkgateway/security_card.go @@ -26,8 +26,8 @@ type SecurityPayment struct { type SecurityCard struct { ISIN string `json:"isin"` Name string `json:"name"` - Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага - Issuer string `json:"issuer,omitempty"` // эмитент + Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага + Issuer string `json:"issuer,omitempty"` // эмитент RegNumber string `json:"reg_number,omitempty"` CFI string `json:"cfi,omitempty"` Face float64 `json:"face,omitempty"` // номинал diff --git a/modules/lk/lk.go b/modules/lk/lk.go index d84116f..c2179bc 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -221,6 +221,34 @@ type portfolioResp struct { Priced int `json:"priced"` } +// operationRow — движение по счёту депо. +type operationRow struct { + Date string `json:"date"` + DoneAt string `json:"done_at"` + Kind string `json:"kind"` + Number string `json:"number"` + Account string `json:"account"` + ISIN string `json:"isin"` + Security string `json:"security"` + Quantity float64 `json:"quantity"` + State string `json:"state"` + Note string `json:"note"` +} + +// incomeRow — выплата дохода. +type incomeRow struct { + Date string `json:"date"` + Kind string `json:"kind"` + ISIN string `json:"isin"` + Security string `json:"security"` + Quantity float64 `json:"quantity"` + Accrued float64 `json:"accrued"` + Tax float64 `json:"tax"` + Paid float64 `json:"paid"` + State string `json:"state"` + Account string `json:"account"` +} + // securityCard — карточка бумаги из ядра. type securityCard struct { ISIN string `json:"isin"` @@ -444,6 +472,69 @@ func (s *Server) Handler() http.Handler { s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin}) }) + // Операции по счетам депо + mux.HandleFunc("/operations", func(w http.ResponseWriter, r *http.Request) { + sess := sessionW(w, r) + if sess == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") + if from == "" { + from = time.Now().AddDate(-1, 0, 0).Format("2006-01-02") + } + if to == "" { + to = time.Now().Format("2006-01-02") + } + var out struct { + Operations []operationRow `json:"operations"` + } + path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) + if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to}) + return + } + var in, outq int + for _, o := range out.Operations { + if o.Quantity < 0 { + outq++ + } else { + in++ + } + } + s.render(w, r, "operations", map[string]any{"Nav": "o", "Operations": out.Operations, + "From": from, "To": to, "Total": len(out.Operations), "In": in, "Out": outq}) + }) + + // Доходы и налоги + mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) { + sess := sessionW(w, r) + if sess == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to") + if from == "" { + from = time.Now().Format("2006") + "-01-01" + } + if to == "" { + to = time.Now().Format("2006-01-02") + } + var out struct { + Income []incomeRow `json:"income"` + Accrued float64 `json:"accrued"` + Paid float64 `json:"paid"` + Tax float64 `json:"tax"` + } + path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to) + if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil { + s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to}) + return + } + s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to, + "Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax}) + }) + // Профиль: договор, счета депо, доступ mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { sess := sessionW(w, r) diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index 9678449..6e3d9fe 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -53,6 +53,26 @@ func fakeBJ(t *testing.T) *httptest.Server { }}) } }) + mux.HandleFunc("/api/lk/operations", 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{"operations": []any{ + map[string]any{"date": "16.09.2026", "kind": "зачисление", "number": "260915/1", "account": "ВТНКЦ00000001", + "isin": "RU0009029540", "security": "Сбербанк, ао", "quantity": 100, "state": "исполнено", "note": "Отчёт клиринговой организации"}, + map[string]any{"date": "14.08.2026", "kind": "списание", "account": "ВТНКЦ00000001", "isin": "RU000A1014L8", + "security": "Ликвидность, пай", "quantity": -120, "state": "исполнено"}, + }}) + } + }) + mux.HandleFunc("/api/lk/income", 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{"accrued": 540.6, "paid": 494.6, "tax": 46.0, "income": []any{ + map[string]any{"date": "02.06.2026", "kind": "дивиденды", "isin": "RU000A106YF0", "security": "ВК, ао", + "quantity": 5, "accrued": 45, "tax": 6, "paid": 39}, + map[string]any{"date": "03.06.2026", "kind": "купон", "isin": "RU000A1038V6", "security": "ОФЗ 26238", + "accrued": 495.6, "tax": 0, "paid": 495.6}, + }}) + } + }) mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) @@ -195,6 +215,18 @@ func TestBoardPages(t *testing.T) { t.Errorf("в карточке бумаги нет %q", want) } } + ops := get("/operations") + for _, want := range []string{"Операции по счетам депо", "зачисление", "Ликвидность, пай", "Отчёт клиринговой организации"} { + if !strings.Contains(ops, want) { + t.Errorf("в операциях нет %q", want) + } + } + inc := get("/income") + for _, want := range []string{"Выплачено дохода", "494,60 ₽", "Удержан налог", "дивиденды"} { + if !strings.Contains(inc, want) { + t.Errorf("в доходах нет %q", want) + } + } prof := get("/profile") if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") { t.Errorf("профиль: %s", prof) diff --git a/modules/lk/templates.go b/modules/lk/templates.go index ac69c0d..e9bb38e 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -99,6 +99,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} Табло Реестр Поручения + Операции + Доходы Профиль Новое поручение @@ -422,4 +424,67 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{end}} +{{define "operations"}}{{template "head" .}}{{template "nav" .}} +
+
Операции по счетам депо +
{{.Total}}
+
за период {{.From}} — {{.To}}
+
+ Зачислений{{.In}} + Списаний{{.Out}} +
+
+
+ {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+ {{if .Operations}}
+ + {{range .Operations}} + + + + + + + + {{end}}
ДатаОперацияВыпускСчёт депоКоличествоСостояниеОснование
{{.Date}}{{if and .DoneAt (ne .DoneAt .Date)}}
проведено {{.DoneAt}}
{{end}}
{{.Kind}}{{with .Number}}
№ {{.}}
{{end}}
{{if .ISIN}}{{.Security}}
{{.ISIN}}
{{else}}{{.Security}}{{end}}
{{.Account}}{{if lt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}+{{fmtnum .Quantity}}{{end}}{{if eq .State "исполнено"}}исполнено{{else if eq .State "отменено"}}отменено{{else}}{{.State}}{{end}}{{if .Note}}{{.Note}}{{else}}—{{end}}
+

Показаны зачисления, списания и переводы между вашими счетами депо по данным учёта депозитария. Выписку за период можно заказать у депозитария.

+ {{else}}

За выбранный период операций по счетам депо нет.

{{end}} +
{{end}} + +{{define "income"}}{{template "head" .}}{{template "nav" .}} +
+
Выплачено дохода +
{{fmtmoney .Paid}} ₽
+
за период {{.From}} — {{.To}}
+
+ Начислено{{fmtmoney .Accrued}} ₽ + Удержан налог{{fmtmoney .Tax}} ₽ +
+
+
+ {{if .Error}}
{{.Error}}
{{end}} +
+
+
+ +
+ {{if .Income}}
+ + {{range .Income}} + + + + + + + + {{end}}
ДатаВыпускВид доходаБумагНачисленоНалогК выплате
{{.Date}}{{if .ISIN}}{{.Security}}
{{.ISIN}}
{{else}}{{.Security}}{{end}}
{{.Kind}}{{if gt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}{{fmtmoney .Accrued}}{{if gt .Tax 0.0}}{{fmtmoney .Tax}}{{else}}—{{end}}{{fmtmoney .Paid}} ₽
+

Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.

+ {{else}}

За выбранный период выплат дохода нет.

{{end}} +
{{end}} `