Кабинет: операции по счетам депо и доходы с удержанным налогом из учётной системы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 23:11:34 +03:00
parent 26785ff3ba
commit 4b18579956
7 changed files with 551 additions and 2 deletions
+45
View File
@@ -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
+274
View File
@@ -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, &note) != 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
}
+42
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -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"` // номинал