Кабинет: операции по счетам депо и доходы с удержанным налогом из учётной системы
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -293,6 +293,51 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
|||||||
lkJSON(w, http.StatusOK, map[string]any{"security": card})
|
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) {
|
mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !auth(w, r) {
|
if !auth(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ type SecurityPayment struct {
|
|||||||
type SecurityCard struct {
|
type SecurityCard struct {
|
||||||
ISIN string `json:"isin"`
|
ISIN string `json:"isin"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага
|
Kind string `json:"kind,omitempty"` // акция | облигация | пай | бумага
|
||||||
Issuer string `json:"issuer,omitempty"` // эмитент
|
Issuer string `json:"issuer,omitempty"` // эмитент
|
||||||
RegNumber string `json:"reg_number,omitempty"`
|
RegNumber string `json:"reg_number,omitempty"`
|
||||||
CFI string `json:"cfi,omitempty"`
|
CFI string `json:"cfi,omitempty"`
|
||||||
Face float64 `json:"face,omitempty"` // номинал
|
Face float64 `json:"face,omitempty"` // номинал
|
||||||
|
|||||||
@@ -221,6 +221,34 @@ type portfolioResp struct {
|
|||||||
Priced int `json:"priced"`
|
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 — карточка бумаги из ядра.
|
// securityCard — карточка бумаги из ядра.
|
||||||
type securityCard struct {
|
type securityCard struct {
|
||||||
ISIN string `json:"isin"`
|
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})
|
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) {
|
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
||||||
sess := sessionW(w, r)
|
sess := sessionW(w, r)
|
||||||
|
|||||||
@@ -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) {
|
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if keyOK(w, r) {
|
if keyOK(w, r) {
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||||
@@ -195,6 +215,18 @@ func TestBoardPages(t *testing.T) {
|
|||||||
t.Errorf("в карточке бумаги нет %q", want)
|
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")
|
prof := get("/profile")
|
||||||
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
|
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
|
||||||
t.Errorf("профиль: %s", prof)
|
t.Errorf("профиль: %s", prof)
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
|||||||
<a href="/" class="lbl {{if eq .Nav "b"}}on{{end}}">Табло</a>
|
<a href="/" class="lbl {{if eq .Nav "b"}}on{{end}}">Табло</a>
|
||||||
<a href="/portfolio" class="lbl {{if eq .Nav "p"}}on{{end}}">Реестр</a>
|
<a href="/portfolio" class="lbl {{if eq .Nav "p"}}on{{end}}">Реестр</a>
|
||||||
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
|
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
|
||||||
|
<a href="/operations" class="lbl {{if eq .Nav "o"}}on{{end}}">Операции</a>
|
||||||
|
<a href="/income" class="lbl {{if eq .Nav "i"}}on{{end}}">Доходы</a>
|
||||||
<a href="/profile" class="lbl {{if eq .Nav "u"}}on{{end}}">Профиль</a>
|
<a href="/profile" class="lbl {{if eq .Nav "u"}}on{{end}}">Профиль</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a href="/order" class="cta lbl"><svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>Новое поручение</a>
|
<a href="/order" class="cta lbl"><svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>Новое поручение</a>
|
||||||
@@ -422,4 +424,67 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main></body></html>{{end}}
|
</main></body></html>{{end}}
|
||||||
|
{{define "operations"}}{{template "head" .}}{{template "nav" .}}
|
||||||
|
<section class="board"><div class="board-row">
|
||||||
|
<div><span class="lbl" style="color:var(--dim)">Операции по счетам депо</span>
|
||||||
|
<div class="big" style="margin-top:8px;font-size:32px">{{.Total}}</div>
|
||||||
|
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
|
||||||
|
<div class="stats">
|
||||||
|
<span class="stat"><span class="lbl" style="color:var(--dim)">Зачислений</span><span class="v">{{.In}}</span></span>
|
||||||
|
<span class="stat"><span class="lbl" style="color:var(--dim)">Списаний</span><span class="v">{{.Out}}</span></span>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
<main>
|
||||||
|
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||||
|
<form method="get" action="/operations" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
|
||||||
|
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
|
||||||
|
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
|
||||||
|
<button class="btn" type="submit">Показать</button>
|
||||||
|
</form>
|
||||||
|
{{if .Operations}}<div class="tw"><table>
|
||||||
|
<thead><tr><th>Дата</th><th>Операция</th><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th>Состояние</th><th>Основание</th></tr></thead>
|
||||||
|
<tbody>{{range .Operations}}<tr>
|
||||||
|
<td class="n">{{.Date}}{{if and .DoneAt (ne .DoneAt .Date)}}<div class="mut">проведено {{.DoneAt}}</div>{{end}}</td>
|
||||||
|
<td>{{.Kind}}{{with .Number}}<div class="n mut">№ {{.}}</div>{{end}}</td>
|
||||||
|
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
|
||||||
|
<td class="n" style="font-size:13px">{{.Account}}</td>
|
||||||
|
<td class="num">{{if lt .Quantity 0.0}}<span style="color:var(--err-tx)">{{fmtnum .Quantity}}</span>{{else}}<span style="color:var(--ok-tx)">+{{fmtnum .Quantity}}</span>{{end}}</td>
|
||||||
|
<td>{{if eq .State "исполнено"}}<span class="chip ok">исполнено</span>{{else if eq .State "отменено"}}<span class="chip err">отменено</span>{{else}}<span class="chip go">{{.State}}</span>{{end}}</td>
|
||||||
|
<td class="mut">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
|
||||||
|
</tr>{{end}}</tbody></table></div>
|
||||||
|
<p class="mut" style="margin-top:18px">Показаны зачисления, списания и переводы между вашими счетами депо по данным учёта депозитария. Выписку за период можно заказать у депозитария.</p>
|
||||||
|
{{else}}<p class="empty">За выбранный период операций по счетам депо нет.</p>{{end}}
|
||||||
|
</main></body></html>{{end}}
|
||||||
|
|
||||||
|
{{define "income"}}{{template "head" .}}{{template "nav" .}}
|
||||||
|
<section class="board"><div class="board-row">
|
||||||
|
<div><span class="lbl" style="color:var(--dim)">Выплачено дохода</span>
|
||||||
|
<div class="big" style="margin-top:8px">{{fmtmoney .Paid}} ₽</div>
|
||||||
|
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
|
||||||
|
<div class="stats">
|
||||||
|
<span class="stat"><span class="lbl" style="color:var(--dim)">Начислено</span><span class="v">{{fmtmoney .Accrued}} ₽</span></span>
|
||||||
|
<span class="stat"><span class="lbl" style="color:var(--dim)">Удержан налог</span><span class="v" style="color:var(--amber)">{{fmtmoney .Tax}} ₽</span></span>
|
||||||
|
</div>
|
||||||
|
</div></section>
|
||||||
|
<main>
|
||||||
|
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||||
|
<form method="get" action="/income" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
|
||||||
|
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
|
||||||
|
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
|
||||||
|
<button class="btn" type="submit">Показать</button>
|
||||||
|
</form>
|
||||||
|
{{if .Income}}<div class="tw"><table>
|
||||||
|
<thead><tr><th>Дата</th><th>Выпуск</th><th>Вид дохода</th><th class="num">Бумаг</th><th class="num">Начислено</th><th class="num">Налог</th><th class="num">К выплате</th></tr></thead>
|
||||||
|
<tbody>{{range .Income}}<tr>
|
||||||
|
<td class="n">{{.Date}}</td>
|
||||||
|
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
|
||||||
|
<td>{{.Kind}}</td>
|
||||||
|
<td class="num">{{if gt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}</td>
|
||||||
|
<td class="num">{{fmtmoney .Accrued}}</td>
|
||||||
|
<td class="num">{{if gt .Tax 0.0}}{{fmtmoney .Tax}}{{else}}—{{end}}</td>
|
||||||
|
<td class="num" style="font-weight:700">{{fmtmoney .Paid}} ₽</td>
|
||||||
|
</tr>{{end}}</tbody></table></div>
|
||||||
|
<p class="mut" style="margin-top:18px">Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.</p>
|
||||||
|
{{else}}<p class="empty">За выбранный период выплат дохода нет.</p>{{end}}
|
||||||
|
</main></body></html>{{end}}
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user