Кабинет клиента: портфель как у брокера — «Онлайн» по QUIK, «Архив» на дату и операции по SPECTRE

- lkclient.go: договор → код QUIK (БР-1545 → BR1545, +OUT), договор → D_B_CONTRACTS SPECTRE
- lkonline.go: позиции DepoLimits (T2, свежая строка на счёт), средняя цена покупки, деньги
  MoneyLimits по валютам, цены и P&L; стоимость, доли
- lkarchive.go: портфель на дату по остаткам OD_RESTS (REG_3 = договор, раздел 39, счета
  50/51/52/55 и 58.01/58.02; 58.05 НКД не показываем) с откатом по OD_TURNS после даты
  (индекс REST+WIRDATE, знак TYPE_ проверен: сумма оборотов = остаток); оценка по котировке
  OD_VALUES_RATES ≤ 90 дней (в рублях за бумагу), иначе балансовая; операции из OD_WIRING
  (покупка/продажа/ввод/вывод/погашение/конвертация, продажа — по счёту ВЫРУЧКА) и
  пополнения/выводы денег; рецепт — InvestControl Hub (build_portfolio_base, deals_report)
- API: /api/lk/portfolio2?view=online|archive&date=, /api/lk/ops (сделки + выплаты дохода);
  без клиента в QUIK онлайн падает на брокерский учёт за сегодня
- кабинет: разделы Портфель (вкладки Онлайн/Архив с датой), Операции, Поручения, Документы,
  Профиль; депозитарные реестр и доходы из клиентского вида убраны (/portfolio → /,
  /income → /operations); проверено на БР-704 и БР-1545
- настройка Fancy.SpectreDatabase (по умолчанию BJ_SPECTRE_DATA)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 15:44:53 +03:00
parent 05d02f22e6
commit 02fabb483d
9 changed files with 1268 additions and 274 deletions
+47 -15
View File
@@ -83,6 +83,32 @@ func fakeBJ(t *testing.T) *httptest.Server {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
})
mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
view := r.URL.Query().Get("view")
asOf := "18.09.2026"
if view == "archive" {
asOf = "31.12.2025"
}
_ = json.NewEncoder(w).Encode(map[string]any{"contract": "BR-1", "full_name": "Клиент Тестовый", "portfolio": map[string]any{
"source": map[bool]string{true: "spectre", false: "quik"}[view == "archive"], "as_of": asOf,
"total": 110268.0, "securities_value": 92698.28, "cash_rub": 17569.72, "cost": 80000.0, "pnl": 12698.28, "pnl_pct": 15.87, "priced": 1,
"cash": []map[string]any{{"currency": "SUR", "amount": 17569.72}},
"positions": []map[string]any{{"isin": "RU0009029540", "name": "Сбербанк, ао", "kind": "акция", "quantity": 336, "avg_price": 238.1, "price": 275.887, "value": 92698.28, "cost": 80000, "pnl": 12698.28, "pnl_pct": 15.87, "day_pct": 1.2, "share": 84.1}},
}})
})
mux.HandleFunc("/api/lk/ops", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"from": "2025-09-18", "to": "2026-09-18", "operations": []map[string]any{
{"date": "10.07.2026", "kind": "продажа", "side": "out", "name": "НОВАТЭК 001P-03", "isin": "RU000A10AUX8", "quantity": 1700, "price": 7793.07, "amount": 13248214.78, "currency": "SUR", "amount_rub": 13248214.78},
{"date": "06.03.2026", "kind": "покупка", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 100, "price": 238.1, "amount": 23810, "currency": "SUR", "amount_rub": 23810},
{"date": "12.05.2026", "kind": "дивиденды", "side": "in", "name": "Сбербанк, ао", "isin": "RU0009029540", "quantity": 336, "amount": 494.6, "currency": "SUR", "amount_rub": 494.6},
}})
})
mux.HandleFunc("/api/lk/admin/bootstrap", func(w http.ResponseWriter, r *http.Request) {
if !keyOK(w, r) {
return
@@ -280,19 +306,31 @@ func TestBoardPages(t *testing.T) {
return rec.Body.String()
}
board := get("/")
for _, want := range []string{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} {
for _, want := range []string{"Стоимость портфеля", "Новое поручение", "Сбербанк, ао", "Онлайн", "Архив", "Денежные средства", "12 698,28 ₽", "15,87 %", "84,1%"} {
if !strings.Contains(board, want) {
t.Errorf("на табло нет %q", want)
t.Errorf("в портфеле нет %q", want)
if i := strings.Index(board, "Результат"); i > 0 {
t.Logf("фрагмент: %s", board[i:min(i+400, len(board))])
}
}
}
if !strings.Contains(board, "110 268 ₽") {
t.Errorf("на табло нет оценки портфеля")
t.Errorf("стоимость портфеля не показана: %s", board)
}
reg := get("/portfolio")
for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} {
if !strings.Contains(reg, want) {
t.Errorf("в реестре нет %q", want)
}
arch := get("/?view=archive&date=2025-12-31")
if !strings.Contains(arch, "на 31.12.2025") || !strings.Contains(arch, `value="2025-12-31"`) {
t.Errorf("архив на дату не показан")
}
// старые адреса ведут на новые разделы
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/portfolio", nil))
if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/" {
t.Errorf("/portfolio должен вести на портфель: %d %s", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/income", nil))
if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/operations" {
t.Errorf("/income должен вести на операции: %d %s", rec.Code, rec.Header().Get("Location"))
}
orders := get("/orders")
if !strings.Contains(orders, "Поручения депозитарию") || !strings.Contains(orders, "Перевод в другой депозитарий") {
@@ -311,17 +349,11 @@ func TestBoardPages(t *testing.T) {
}
}
ops := get("/operations")
for _, want := range []string{"Операции по счетам депо", "зачисление", "Ликвидность, пай", "Отчёт клиринговой организации"} {
for _, want := range []string{"Операции", "продажа", "покупка", "дивиденды", "НОВАТЭК 001P-03", "−13 248 214,78 ₽", "+494,60 ₽", "Продажи"} {
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)