diff --git a/internal/lkgateway/cbrfx.go b/internal/lkgateway/cbrfx.go new file mode 100644 index 0000000..3c3f1a8 --- /dev/null +++ b/internal/lkgateway/cbrfx.go @@ -0,0 +1,187 @@ +package lkgateway + +// cbrfx.go — официальные курсы валют Банка России. +// +// Оценивать валютные выпуски по курсу из учётной системы нельзя: в копии DEPO он +// от 31.05.2026, в SPECTRE — от 18.07.2026, то есть отстаёт на месяцы. Банк России +// публикует курсы открыто и без ключа (XML, windows-1251), тем же каналом ими +// пользуется Инвест-хаб. Забранные курсы складываем в lk.fx_rates: в закрытом +// контуре без интернета кабинет продолжит считать по последнему известному курсу, +// а для архива на дату курс нужен именно на тот день. + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/text/encoding/charmap" +) + +// cbrDailyURL — «Курсы валют на дату» (без date_req — на сегодня). +const cbrDailyURL = "https://www.cbr.ru/scripts/XML_daily.asp" + +// lkFxSchema — курсы валют по дням: source = cbr | accounting. +const lkFxSchema = ` +CREATE TABLE IF NOT EXISTS lk.fx_rates ( + day DATE NOT NULL, code VARCHAR(8) NOT NULL, rate DOUBLE PRECISION NOT NULL, + source VARCHAR(16) NOT NULL DEFAULT 'cbr', fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (day, code))` + +type cbrDoc struct { + Date string `xml:"Date,attr"` + Valute []struct { + CharCode string `xml:"CharCode"` + Nominal string `xml:"Nominal"` + Value string `xml:"Value"` + } `xml:"Valute"` +} + +// cbrFetch — курсы ЦБ на дату (нулевая дата = на сегодня). Возвращает дату, на +// которую курс установлен: в выходные ЦБ отдаёт курс ближайшего рабочего дня. +func cbrFetch(ctx context.Context, day time.Time) (time.Time, map[string]float64, error) { + url := cbrDailyURL + if !day.IsZero() { + url += "?date_req=" + day.Format("02/01/2006") + } + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil) + if err != nil { + return time.Time{}, nil, err + } + req.Header.Set("User-Agent", "bj/1.0 (+depozitarij)") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return time.Time{}, nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return time.Time{}, nil, fmt.Errorf("Банк России ответил %s", resp.Status) + } + var doc cbrDoc + dec := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)) + // Ответ в windows-1251; коды валют латиницей, но без раскодировки Go + // откажется разбирать документ целиком. + dec.CharsetReader = func(enc string, in io.Reader) (io.Reader, error) { + switch strings.ToLower(strings.TrimSpace(enc)) { + case "windows-1251", "cp1251": + return charmap.Windows1251.NewDecoder().Reader(in), nil + } + return in, nil + } + if err := dec.Decode(&doc); err != nil { + return time.Time{}, nil, err + } + at, _ := time.ParseInLocation("02.01.2006", doc.Date, time.Local) + out := make(map[string]float64, len(doc.Valute)) + for _, v := range doc.Valute { + code := strings.ToUpper(strings.TrimSpace(v.CharCode)) + val, err1 := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(v.Value), ",", "."), 64) + nom, err2 := strconv.ParseFloat(strings.TrimSpace(v.Nominal), 64) + if code == "" || err1 != nil || val <= 0 { + continue + } + if err2 != nil || nom <= 0 { + nom = 1 + } + out[code] = val / nom // курс за одну единицу валюты + } + if len(out) == 0 { + return at, nil, fmt.Errorf("Банк России не вернул ни одного курса") + } + return at, out, nil +} + +// ---- хранение ---- + +// lkFxSave — сложить курсы дня в базу (перезаписывая курс того же дня). +func lkFxSave(ctx context.Context, rc *RuntimeConfig, day time.Time, source string, rates map[string]float64) { + db, err := lkDB(rc) + if err != nil || day.IsZero() || len(rates) == 0 { + return + } + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + for code, rate := range rates { + _, _ = db.ExecContext(cctx, `INSERT INTO lk.fx_rates(day, code, rate, source, fetched_at) + VALUES ($1,$2,$3,$4, now()) + ON CONFLICT (day, code) DO UPDATE SET rate = EXCLUDED.rate, source = EXCLUDED.source, fetched_at = now()`, + day.Format("2006-01-02"), code, rate, source) + } +} + +// lkFxLoad — последний известный курс каждой валюты не позже дня day +// (нулевая дата = последний известный вообще). +func lkFxLoad(ctx context.Context, rc *RuntimeConfig, day time.Time) map[string]LKFxRate { + out := map[string]LKFxRate{} + db, err := lkDB(rc) + if err != nil { + return out + } + cctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + upto := "9999-12-31" + if !day.IsZero() { + upto = day.Format("2006-01-02") + } + rows, err := db.QueryContext(cctx, `SELECT DISTINCT ON (code) code, day, rate + FROM lk.fx_rates WHERE day <= $1 ORDER BY code, day DESC`, upto) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var code string + var at time.Time + var rate float64 + if rows.Scan(&code, &at, &rate) == nil && rate > 0 { + out[strings.ToUpper(code)] = LKFxRate{Rate: rate, At: at} + } + } + return out +} + +// ---- курс на сегодня ---- + +var cbrCache = struct { + mu sync.Mutex + at time.Time // когда ходили в ЦБ + day time.Time // на какую дату курс + m map[string]float64 + err string +}{} + +// cbrToday — курсы ЦБ на сегодня с кэшем на час. Ошибку запоминаем, чтобы +// в закрытом контуре не ходить в недоступный интернет на каждой странице. +func cbrToday(ctx context.Context, rc *RuntimeConfig) (map[string]LKFxRate, string) { + cbrCache.mu.Lock() + fresh := time.Since(cbrCache.at) < time.Hour + m, day, cerr := cbrCache.m, cbrCache.day, cbrCache.err + cbrCache.mu.Unlock() + if !fresh { + at, rates, err := cbrFetch(ctx, time.Time{}) + cbrCache.mu.Lock() + cbrCache.at = time.Now() + if err != nil { + cbrCache.err = err.Error() + } else { + cbrCache.m, cbrCache.day, cbrCache.err = rates, at, "" + } + m, day, cerr = cbrCache.m, cbrCache.day, cbrCache.err + cbrCache.mu.Unlock() + if err == nil { + lkFxSave(ctx, rc, at, "cbr", rates) + } + } + out := make(map[string]LKFxRate, len(m)) + for code, rate := range m { + out[code] = LKFxRate{Rate: rate, At: day} + } + return out, cerr +} diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index 3eca2f6..ec2e837 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "sort" + "strconv" "strings" "time" ) @@ -206,6 +207,10 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { "read_only": readOnly, "total_value": total, // оценка портфеля, 0 — цен нет "priced": priced, // по скольким бумагам есть цена + // Контакты из анкеты: клиент видит, куда депозитарий будет писать и звонить, + // и сразу заметит, если данные устарели. Менять их в кабинете нельзя. + "email": u.Email, "phone": u.Phone, + "last_login": lkTimeRU(u.LastLoginAt), }) }) @@ -275,6 +280,30 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { // Портфель клиента как у брокера: view=online — по экспорту QUIK (что есть сейчас), если // в QUIK клиента нет — по брокерскому учёту на сегодня; view=archive&date=YYYY-MM-DD — // портфель на дату по брокерскому учёту (SPECTRE). + // История стоимости портфеля: ежедневные срезы (lksnapshot.go), для графика. + mux.HandleFunc("/api/lk/history", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + days := 180 + if v, err := strconv.Atoi(r.URL.Query().Get("days")); err == nil && v > 0 { + days = v + } + points, err := lkSnapshotHistory(r.Context(), rc, u.Contract, days) + if err != nil { + lkJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + set := rc.Snapshot().LK + hour, min := lkSnapshotTime(set) + lkJSON(w, http.StatusOK, map[string]any{"points": points, "on": lkSnapshotEnabled(rc), + "at": fmt.Sprintf("%02d:%02d", hour, min), "days": days}) + }) + mux.HandleFunc("/api/lk/portfolio2", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return diff --git a/internal/lkgateway/lkarchive.go b/internal/lkgateway/lkarchive.go index 2c1c364..566820c 100644 --- a/internal/lkgateway/lkarchive.go +++ b/internal/lkgateway/lkarchive.go @@ -313,7 +313,36 @@ type LKOperationRow struct { } // lkArchiveOperations — сделки и движения по портфельным счетам договора за период. +// Лента операций за период не меняется в течение дня, а год данных из журнала +// проводок читается секундами: держим готовый ответ пять минут. +var opsCache = struct { + mu sync.Mutex + m map[string]opsCached +}{m: map[string]opsCached{}} + +type opsCached struct { + at time.Time + rows []LKOperationRow +} + func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) { + key := lkContractNumber(contract) + "|" + from.Format("2006-01-02") + "|" + to.Format("2006-01-02") + opsCache.mu.Lock() + c, ok := opsCache.m[key] + opsCache.mu.Unlock() + if ok && time.Since(c.at) < 5*time.Minute { + return c.rows, nil + } + rows, err := lkArchiveOperationsLoad(ctx, s, contract, from, to) + if err == nil { + opsCache.mu.Lock() + opsCache.m[key] = opsCached{at: time.Now(), rows: rows} + opsCache.mu.Unlock() + } + return rows, err +} + +func lkArchiveOperationsLoad(ctx context.Context, s FancyMSSQLSettings, contract string, from, to time.Time) ([]LKOperationRow, error) { c, ok := spectreContractFor(ctx, s, contract) if !ok { return nil, fmt.Errorf("договор %s в брокерском учёте не найден", lkContractNumber(contract)) @@ -351,19 +380,53 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str toEnd := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1) fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC) + // Три обхода журнала проводок независимы, а каждый по году данных идёт секунды: + // выполняем их разом, иначе клиент ждёт их сумму. + var wg sync.WaitGroup + var mu sync.Mutex + var cashOut []LKOperationRow + var proceeds map[string][2]float64 + wg.Add(1) + go func() { + defer wg.Done() + proceeds = spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd) + }() + wg.Add(1) + go func() { + defer wg.Done() + cashOut = spectreCashOps(cctx, conn, cashIDs, fromDay, toEnd) + }() + if len(secIDs) > 0 { in := strings.Join(secIDs, ",") - wrows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, - CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float), - ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'') - FROM OD_WIRING w - JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END - LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2 - LEFT JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END - LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID - WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000 - AND w.WIRDATE >= @f AND w.WIRDATE < @t AND (w.D_AMOUNT <> 0 OR w.K_AMOUNT <> 0) - ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd)) + // Сторона проводки выбирается заранее двумя ветвями UNION ALL: связка + // «JOIN … ON sr.ID = CASE WHEN w.D_REST IN (…)» заставляла SQL Server + // разбирать список счетов на каждой строке журнала — год данных читался + // 6–7 секунд, сейчас — десятки миллисекунд (оба индекса по D_REST/K_REST + // с WIRDATE отрабатывают поиском). + wrows, err := conn.QueryContext(cctx, fmt.Sprintf(` + WITH w AS ( + SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_AMOUNT, wr.K_AMOUNT, wr.NAME, + wr.D_REST AS sec_rest, wr.K_REST AS money_rest + FROM OD_WIRING wr + WHERE wr.D_REST IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000 + AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND (wr.D_AMOUNT <> 0 OR wr.K_AMOUNT <> 0) + UNION ALL + SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_AMOUNT, wr.K_AMOUNT, wr.NAME, + wr.K_REST, wr.D_REST + FROM OD_WIRING wr + WHERE wr.K_REST IN (%[1]s) AND wr.D_REST NOT IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000 + AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND (wr.D_AMOUNT <> 0 OR wr.K_AMOUNT <> 0)) + SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, + CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), + CAST(ISNULL(w.D_AMOUNT,0) AS float), CAST(ISNULL(w.K_AMOUNT,0) AS float), + ISNULL(w.NAME,''), ISNULL(sv.NAME,''), ISNULL(sv.ISIN,''), ISNULL(mv.SYSNAME,'') + FROM w + LEFT JOIN OD_RESTS sr ON sr.ID = w.sec_rest + LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2 + LEFT JOIN OD_RESTS mr ON mr.ID = w.money_rest + LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID + ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd)) if err != nil { return nil, fmt.Errorf("проводки: %v", err) } @@ -405,7 +468,7 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str wrows.Close() // При продаже счёт ПОРТФЕЛЬ списывает бумагу по себестоимости; фактическая выручка — // на счёте ВЫРУЧКА. Клиенту показываем именно её. - proceeds := spectreProceeds(cctx, conn, c.DOC, fromDay, toEnd) + wg.Wait() for _, k := range order { a := agg[k] if a.Kind == "продажа" || a.Kind == "погашение" { @@ -419,50 +482,10 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str out = append(out, *a) } } - // Деньги: пополнения и выводы — проводки по кассовым счетам с чужой стороной вне портфеля договора. - if len(cashIDs) > 0 { - in := strings.Join(cashIDs, ",") - crows, err := conn.QueryContext(cctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, - CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'') - FROM OD_WIRING w - JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END - LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID - WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000 - AND w.WIRDATE >= @f AND w.WIRDATE < @t - ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd)) - if err == nil { - cashSet := map[string]bool{} - for _, id := range cashIDs { - cashSet[id] = true - } - for crows.Next() { - var wd time.Time - var eq, dSum, kSum float64 - var dRest, kRest int64 - var wname, cur string - if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil { - continue - } - low := strings.ToLower(wname) - var kind, side string - switch { - case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"): - kind, side = "пополнение", "in" - case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"): - kind, side = "вывод денег", "out" - default: - continue - } - sum := kSum - if cashSet[fmt.Sprint(dRest)] { - sum = dSum - } - out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства", - Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq}) - } - crows.Close() - } - } + wg.Wait() + mu.Lock() + out = append(out, cashOut...) + mu.Unlock() sort.SliceStable(out, func(i, j int) bool { di, _ := time.Parse("02.01.2006", out[i].Date) dj, _ := time.Parse("02.01.2006", out[j].Date) @@ -471,6 +494,57 @@ func lkArchiveOperations(ctx context.Context, s FancyMSSQLSettings, contract str return out, nil } +// spectreCashOps — пополнения и выводы денег по кассовым счетам договора. +func spectreCashOps(ctx context.Context, conn *sql.DB, cashIDs []string, fromDay, toEnd time.Time) []LKOperationRow { + if len(cashIDs) == 0 { + return nil + } + var out []LKOperationRow + in := strings.Join(cashIDs, ",") + crows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, + CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(w.NAME,''), ISNULL(mv.SYSNAME,'') + FROM OD_WIRING w + JOIN OD_RESTS mr ON mr.ID = CASE WHEN w.D_REST IN (%[1]s) THEN w.D_REST ELSE w.K_REST END + LEFT JOIN OD_VALUES mv ON mv.ID = mr.VALUE_ID + WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000 + AND w.WIRDATE >= @f AND w.WIRDATE < @t + ORDER BY w.WIRDATE`, in), sql.Named("f", fromDay), sql.Named("t", toEnd)) + if err != nil { + return nil + } + defer crows.Close() + cashSet := map[string]bool{} + for _, id := range cashIDs { + cashSet[id] = true + } + for crows.Next() { + var wd time.Time + var eq, dSum, kSum float64 + var dRest, kRest int64 + var wname, cur string + if crows.Scan(&wd, &eq, &dRest, &kRest, &dSum, &kSum, &wname, &cur) != nil { + continue + } + low := strings.ToLower(wname) + var kind, side string + switch { + case strings.Contains(low, "ввод") && strings.Contains(low, "ден"), strings.Contains(low, "пополнен"), strings.Contains(low, "зачисление денеж"): + kind, side = "пополнение", "in" + case strings.Contains(low, "вывод") && strings.Contains(low, "ден"), strings.Contains(low, "списание денеж") && !strings.Contains(low, "комисс"): + kind, side = "вывод денег", "out" + default: + continue + } + sum := kSum + if cashSet[fmt.Sprint(dRest)] { + sum = dSum + } + out = append(out, LKOperationRow{Date: wd.Format("02.01.2006"), Kind: kind, Side: side, Name: "Денежные средства", + Amount: sum, Currency: spectreCurCode(cur), AmountRUB: eq}) + } + return out +} + // spectreDealKind — вид операции по названию проводки (рецепт хаба). Пусто — не показываем. func spectreDealKind(name string, onDebit bool) string { n := strings.ToLower(name) @@ -515,13 +589,22 @@ func spectreProceeds(ctx context.Context, conn *sql.DB, doc int64, from, to time return out } in := strings.Join(ids, ",") - wrows, err := conn.QueryContext(ctx, fmt.Sprintf(`SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, - CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'') - FROM OD_WIRING w - JOIN OD_RESTS sr ON sr.ID = CASE WHEN w.K_REST IN (%[1]s) THEN w.K_REST ELSE w.D_REST END - LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2 - WHERE (w.D_REST IN (%[1]s) OR w.K_REST IN (%[1]s)) AND w.IS_PLAN = 'F' AND YEAR(w.WIRDATE) < 9000 - AND w.WIRDATE >= @f AND w.WIRDATE < @t AND w.EQ_ <> 0`, in), sql.Named("f", from), sql.Named("t", to)) + wrows, err := conn.QueryContext(ctx, fmt.Sprintf(` + WITH w AS ( + SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.D_REST AS sec_rest + FROM OD_WIRING wr + WHERE wr.K_REST IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000 + AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND wr.EQ_ <> 0 + UNION ALL + SELECT wr.WIRDATE, wr.EQ_, wr.D_REST, wr.K_REST, wr.D_SUMMA, wr.K_SUMMA, wr.K_REST + FROM OD_WIRING wr + WHERE wr.D_REST IN (%[1]s) AND wr.K_REST NOT IN (%[1]s) AND wr.IS_PLAN = 'F' AND YEAR(wr.WIRDATE) < 9000 + AND wr.WIRDATE >= @f AND wr.WIRDATE < @t AND wr.EQ_ <> 0) + SELECT w.WIRDATE, CAST(ISNULL(w.EQ_,0) AS float), w.D_REST, w.K_REST, + CAST(ISNULL(w.D_SUMMA,0) AS float), CAST(ISNULL(w.K_SUMMA,0) AS float), ISNULL(sv.ISIN,'') + FROM w + LEFT JOIN OD_RESTS sr ON sr.ID = w.sec_rest + LEFT JOIN OD_VALUES sv ON sv.ID = sr.REG_2`, in), sql.Named("f", from), sql.Named("t", to)) if err != nil { return out } diff --git a/internal/lkgateway/lkdata.go b/internal/lkgateway/lkdata.go new file mode 100644 index 0000000..7b0b462 --- /dev/null +++ b/internal/lkgateway/lkdata.go @@ -0,0 +1,117 @@ +package lkgateway + +// lkdata.go — карточка «Данные кабинета» в настройках: откуда берём курсы валют +// и ведём ли ежедневный срез стоимости портфелей (для графика у клиента). + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" +) + +// LKDataView — состояние источников данных кабинета для страницы настроек. +type LKDataView struct { + FXSource string // cbr | accounting + FXAt string // дата последнего известного курса + FXCount int // сколько валют в последнем комплекте + FXNote string // пояснение/ошибка связи с Банком России + SnapshotMode string // "" (авто) | on | off + SnapshotOn bool // ведём ли по факту + SnapshotAt string // ЧЧ:ММ + Snapshots int // сколько дней истории накоплено + SnapLast string // последний снятый срез + SnapRows int // строк в последнем срезе +} + +// lkDataView — собрать состояние без обращения в интернет (карточку открывают часто). +func lkDataView(rc *RuntimeConfig) LKDataView { + s := rc.Snapshot().LK + hour, min := lkSnapshotTime(s) + v := LKDataView{FXSource: strings.TrimSpace(s.FXSource), SnapshotMode: strings.TrimSpace(s.SnapshotMode), + SnapshotOn: lkSnapshotEnabled(rc), SnapshotAt: fmt.Sprintf("%02d:%02d", hour, min)} + if v.FXSource == "" { + v.FXSource = "cbr" + } + db, err := lkDB(rc) + if err != nil { + v.FXNote = "база bj недоступна: " + err.Error() + return v + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var day *time.Time + var n int + if db.QueryRowContext(ctx, `SELECT max(day) FROM lk.fx_rates`).Scan(&day) == nil && day != nil { + v.FXAt = day.Format("02.01.2006") + _ = db.QueryRowContext(ctx, `SELECT count(*) FROM lk.fx_rates WHERE day = $1`, *day).Scan(&n) + v.FXCount = n + } + var sday *time.Time + if db.QueryRowContext(ctx, `SELECT count(DISTINCT day), max(day) FROM lk.portfolio_daily`).Scan(&v.Snapshots, &sday) == nil && sday != nil { + v.SnapLast = sday.Format("02.01.2006") + _ = db.QueryRowContext(ctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`, *sday).Scan(&v.SnapRows) + } + return v +} + +// lkDataSave — POST /admin/setup/lk/data: источник курсов и ежедневный срез. +func (h *setupHandlers) lkDataSave(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + switch r.FormValue("op") { + case "fx_now": // проверить связь с Банком России и обновить курсы + at, rates, err := cbrFetch(r.Context(), time.Time{}) + if err != nil { + setupFlash(w, r, "Банк России недоступен: "+err.Error()+". Курсы останутся прежними.") + return + } + lkFxSave(r.Context(), h.rc, at, "cbr", rates) + lkFxReset() + setupFlash(w, r, fmt.Sprintf("Курсы Банка России на %s получены: валют %d.", at.Format("02.01.2006"), len(rates))) + return + case "snapshot_now": // снять срез стоимости прямо сейчас + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + n, err := lkSnapshotRun(ctx, h.rc, time.Now()) + if err != nil { + return + } + _ = n + }() + setupFlash(w, r, "Срез стоимости портфелей снимается — на 1300 договоров это несколько минут; обновите страницу.") + return + } + s := h.rc.Snapshot().LK + if v := strings.TrimSpace(r.FormValue("fx_source")); v == "cbr" || v == "accounting" { + s.FXSource = v + } + switch v := strings.TrimSpace(r.FormValue("snapshot_mode")); v { + case "", "on", "off": + s.SnapshotMode = v + } + if v := strings.TrimSpace(r.FormValue("snapshot_at")); len(v) == 5 && v[2] == ':' { + s.SnapshotAt = v + } + if err := h.rc.UpdateLK(s); err != nil { + setupFlash(w, r, "Данные кабинета: ошибка сохранения: "+err.Error()) + return + } + lkFxReset() + src := "Банк России" + if s.FXSource == "accounting" { + src = "учётная система" + } + msg := "Курсы валют: " + src + "." + if s.SnapshotMode == "off" { + msg += " Ежедневный срез выключен — график в кабинете обновляться не будет." + } else { + hour, min := lkSnapshotTime(s) + msg += fmt.Sprintf(" Срез стоимости портфелей — ежедневно в %02d:%02d.", hour, min) + } + setupFlash(w, r, msg) +} diff --git a/internal/lkgateway/lkfx.go b/internal/lkgateway/lkfx.go index 45e82c4..f2e4f23 100644 --- a/internal/lkgateway/lkfx.go +++ b/internal/lkgateway/lkfx.go @@ -29,8 +29,35 @@ var fxCache = struct { m map[string]LKFxRate }{} -// lkFxRates — курсы валют (ключ — код валюты в верхнем регистре), кэш на час. -func lkFxRates(ctx context.Context, s FancyMSSQLSettings) map[string]LKFxRate { +// lkFxRates — курсы валют для оценки (ключ — код валюты в верхнем регистре). +// Источник по умолчанию — Банк России (см. cbrfx.go); если интернета нет или +// в настройках выбрана учётная система, берём OD_VALUES_RATES, а недостающие +// валюты дополняем последним известным курсом из lk.fx_rates. +func lkFxRates(ctx context.Context, rc *RuntimeConfig) map[string]LKFxRate { + set := rc.Snapshot() + out := map[string]LKFxRate{} + if !strings.EqualFold(strings.TrimSpace(set.LK.FXSource), "accounting") { + if cbr, cerr := cbrToday(ctx, rc); len(cbr) > 0 { + return cbr + } else if cerr != "" { + // молча уходим на запасные источники: кабинет не должен падать + // из-за недоступного сайта ЦБ + _ = cerr + } + } + for code, r := range lkFxAccounting(ctx, set.Fancy) { + out[code] = r + } + for code, r := range lkFxLoad(ctx, rc, time.Time{}) { + if old, ok := out[code]; !ok || r.At.After(old.At) { + out[code] = r + } + } + return out +} + +// lkFxAccounting — курсы из учётной системы (OD_VALUES_RATES, MARKET = 0), кэш на час. +func lkFxAccounting(ctx context.Context, s FancyMSSQLSettings) map[string]LKFxRate { fxCache.mu.Lock() if fxCache.m != nil && time.Since(fxCache.at) < time.Hour { m := fxCache.m @@ -89,3 +116,13 @@ func lkFxFor(rates map[string]LKFxRate, cur string) LKFxRate { } return rates[c] } + +// lkFxReset — сбросить кэши курсов (после смены источника или ручного обновления). +func lkFxReset() { + fxCache.mu.Lock() + fxCache.m, fxCache.at = nil, time.Time{} + fxCache.mu.Unlock() + cbrCache.mu.Lock() + cbrCache.at, cbrCache.err = time.Time{}, "" + cbrCache.mu.Unlock() +} diff --git a/internal/lkgateway/lkhistory.go b/internal/lkgateway/lkhistory.go index c5083a4..5e751b0 100644 --- a/internal/lkgateway/lkhistory.go +++ b/internal/lkgateway/lkhistory.go @@ -103,7 +103,7 @@ func inClause(ids []int64) string { 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(0, -3, 0) // по умолчанию — последние три месяца + fromT = time.Now().AddDate(-1, 0, 0) // по умолчанию — последний год } toT, err := time.Parse("2006-01-02", strings.TrimSpace(to)) if err != nil { diff --git a/internal/lkgateway/lksnapshot.go b/internal/lkgateway/lksnapshot.go new file mode 100644 index 0000000..95dc576 --- /dev/null +++ b/internal/lkgateway/lksnapshot.go @@ -0,0 +1,226 @@ +package lkgateway + +// lksnapshot.go — ежедневный срез стоимости портфелей для графика в кабинете. +// +// Ни QUIK, ни SPECTRE не хранят историю стоимости: экспорт QUIK показывает позиции +// «сейчас», SPECTRE — остатки на дату, но переоценивать каждый день задним числом +// дорого и всё равно неточно (котировки прошлых дней в копии не полны). Поэтому раз +// в день, после закрытия торгов, считаем стоимость каждого портфеля по экспорту QUIK +// и складываем одну строку на договор в lk.portfolio_daily. Из этих строк кабинет +// рисует график: чем дольше работает система, тем длиннее история. + +import ( + "context" + "log" + "strings" + "time" +) + +const lkSnapshotSchema = ` +CREATE TABLE IF NOT EXISTS lk.portfolio_daily ( + day DATE NOT NULL, contract VARCHAR(64) NOT NULL, + value DOUBLE PRECISION NOT NULL DEFAULT 0, cash DOUBLE PRECISION NOT NULL DEFAULT 0, + positions INT NOT NULL DEFAULT 0, priced INT NOT NULL DEFAULT 0, + source VARCHAR(16) NOT NULL DEFAULT 'quik', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (day, contract))` + +// lkSnapshotEnabled — ведём ли историю стоимости. По умолчанию (режим не выбран) +// ведём, если настроен экспорт QUIK: история накапливается только вперёд, и +// упущенные дни потом не восстановить. +func lkSnapshotEnabled(rc *RuntimeConfig) bool { + switch strings.ToLower(strings.TrimSpace(rc.Snapshot().LK.SnapshotMode)) { + case "on": + return true + case "off": + return false + } + _, err := nprDB(rc) + return err == nil +} + +// lkSnapshotTime — когда снимаем срез (по умолчанию 20:30, после закрытия основной сессии). +func lkSnapshotTime(s LKSettings) (hour, min int) { + hour, min = 20, 30 + v := strings.TrimSpace(s.SnapshotAt) + if len(v) == 5 && v[2] == ':' { + h := int(v[0]-'0')*10 + int(v[1]-'0') + m := int(v[3]-'0')*10 + int(v[4]-'0') + if h >= 0 && h < 24 && m >= 0 && m < 60 { + hour, min = h, m + } + } + return hour, min +} + +// LKDailyPoint — точка графика стоимости портфеля. +type LKDailyPoint struct { + Day string `json:"day"` // 2026-09-18 + Value float64 `json:"value"` // бумаги, ₽ + Cash float64 `json:"cash"` // деньги в рублях + Total float64 `json:"total"` // бумаги + деньги + Positions int `json:"positions"` +} + +// lkSnapshotHistory — история стоимости портфеля за последние days дней. +func lkSnapshotHistory(ctx context.Context, rc *RuntimeConfig, contract string, days int) ([]LKDailyPoint, error) { + db, err := lkDB(rc) + if err != nil { + return nil, err + } + num := lkContractNumber(contract) + if num == "" { + return nil, nil + } + if days <= 0 || days > 3650 { + days = 365 + } + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + rows, err := db.QueryContext(cctx, `SELECT day, value, cash, positions FROM lk.portfolio_daily + WHERE contract = $1 AND day >= current_date - $2::int ORDER BY day`, num, days) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LKDailyPoint + for rows.Next() { + var p LKDailyPoint + var day time.Time + if rows.Scan(&day, &p.Value, &p.Cash, &p.Positions) != nil { + continue + } + p.Day = day.Format("2006-01-02") + p.Total = p.Value + p.Cash + out = append(out, p) + } + return out, rows.Err() +} + +// lkSnapshotSave — записать срез одного портфеля за день (повторный запуск перезаписывает). +func lkSnapshotSave(ctx context.Context, rc *RuntimeConfig, day time.Time, contract string, p *LKPortfolio) error { + db, err := lkDB(rc) + if err != nil { + return err + } + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _, err = db.ExecContext(cctx, `INSERT INTO lk.portfolio_daily(day, contract, value, cash, positions, priced, source) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (day, contract) DO UPDATE SET value = EXCLUDED.value, cash = EXCLUDED.cash, + positions = EXCLUDED.positions, priced = EXCLUDED.priced, source = EXCLUDED.source, created_at = now()`, + day.Format("2006-01-02"), lkContractNumber(contract), p.Securities, p.CashRUB, + len(p.Positions), p.Priced, p.Source) + return err +} + +// lkSnapshotContracts — чьи портфели снимаем: договоры, по которым в экспорте QUIK +// есть ненулевые позиции. Берём коды клиентов из экспорта и переводим их в номера +// договоров депозитария (BR746 → БР-746), иначе пришлось бы перебирать 1300 договоров. +func lkSnapshotContracts(ctx context.Context, rc *RuntimeConfig) []string { + p, err := nprDB(rc) + if err != nil { + return nil + } + cctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + rows, err := p.Query(cctx, `SELECT DISTINCT "ClientCode" FROM "DepoLimits" + WHERE "LimitKind" = 'T2' AND "CurrentBal" <> 0 AND "ClientCode" <> ''`) + if err != nil { + return nil + } + defer rows.Close() + live := map[string]bool{} + for rows.Next() { + var code string + if rows.Scan(&code) == nil { + live[strings.ToUpper(strings.TrimSpace(code))] = true + } + } + // номер договора знает только депозитарий: сопоставляем его коды с живыми в QUIK + var out []string + seen := map[string]bool{} + for _, full := range depoSuggestContracts(ctx, rc.Snapshot().Fancy) { + num := lkContractNumber(full) + if num == "" || seen[num] { + continue + } + for _, code := range lkQuikCodes(num) { + if live[strings.ToUpper(code)] { + seen[num] = true + out = append(out, num) + break + } + } + } + return out +} + +// lkSnapshotRun — один проход: считает и сохраняет срез за день по всем портфелям. +func lkSnapshotRun(ctx context.Context, rc *RuntimeConfig, day time.Time) (int, error) { + contracts := lkSnapshotContracts(ctx, rc) + saved := 0 + for _, num := range contracts { + select { + case <-ctx.Done(): + return saved, ctx.Err() + default: + } + pf, err := lkOnlinePortfolio(ctx, rc, num) + if err != nil || pf == nil { + continue + } + if err := lkSnapshotSave(ctx, rc, day, num, pf); err == nil { + saved++ + } + } + return saved, nil +} + +// lkSnapshotLoop — раз в день в назначенное время снимает срез. Если сервер был +// выключен и срез за сегодня не снят, а время уже прошло — снимаем при старте. +func (s *Server) lkSnapshotLoop(ctx context.Context) { + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if !lkSnapshotEnabled(s.rc) { + continue + } + set := s.rc.Snapshot() + hour, min := lkSnapshotTime(set.LK) + now := time.Now() + if now.Hour() < hour || (now.Hour() == hour && now.Minute() < min) { + continue + } + day := now.Truncate(24 * time.Hour) + if lkSnapshotDone(ctx, s.rc, now) { + continue + } + n, err := lkSnapshotRun(ctx, s.rc, day) + if err != nil { + log.Printf("кабинет: срез стоимости портфелей прерван: %v", err) + continue + } + log.Printf("кабинет: срез стоимости портфелей за %s — %d договоров", now.Format("02.01.2006"), n) + } +} + +// lkSnapshotDone — срез за этот день уже снят. +func lkSnapshotDone(ctx context.Context, rc *RuntimeConfig, day time.Time) bool { + db, err := lkDB(rc) + if err != nil { + return true // без базы срез всё равно не сохранить, и жаловаться каждые 5 минут незачем + } + cctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var n int + if db.QueryRowContext(cctx, `SELECT count(*) FROM lk.portfolio_daily WHERE day = $1`, + day.Format("2006-01-02")).Scan(&n) != nil { + return true + } + return n > 0 +} diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go index 4b6b33a..829cc48 100644 --- a/internal/lkgateway/lkstore.go +++ b/internal/lkgateway/lkstore.go @@ -100,7 +100,9 @@ var lkSchemaDDL = []string{ // ensureLKSchema — схема lk и таблицы модуля ЛК (идемпотентно), при старте bj. // lkSchemaExtra — таблицы, добавленные позже основной схемы. -func lkSchemaExtra() []string { return []string{lkDocSchema, lkAuditSchema} } +func lkSchemaExtra() []string { + return []string{lkDocSchema, lkAuditSchema, lkFxSchema, lkSnapshotSchema} +} func ensureLKSchema(ctx context.Context, rc *RuntimeConfig) error { db, err := lkDB(rc) diff --git a/internal/lkgateway/lkusers.go b/internal/lkgateway/lkusers.go index 2a6a67d..016782f 100644 --- a/internal/lkgateway/lkusers.go +++ b/internal/lkgateway/lkusers.go @@ -214,3 +214,11 @@ func lkFilterUsers(list []LKUser, search string) []LKUser { } return out } + +// lkTimeRU — время для кабинета: «18.09.2026 19:40», пусто — если не было. +func lkTimeRU(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Local().Format("02.01.2006 15:04") +} diff --git a/internal/lkgateway/prices.go b/internal/lkgateway/prices.go index ed2d0ad..d38c706 100644 --- a/internal/lkgateway/prices.go +++ b/internal/lkgateway/prices.go @@ -85,7 +85,7 @@ func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string } cctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() - fxRates := lkFxRates(ctx, rc.Snapshot().Fancy) // для выпусков с валютным номиналом + fxRates := lkFxRates(ctx, rc) // для выпусков с валютным номиналом // Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB, // корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами. rows, err := p.Query(cctx, ` diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 546e9ec..fdc225e 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -269,6 +269,16 @@ type LKSettings struct { Test LKEndpoint `json:"lk_test"` Prod LKEndpoint `json:"lk_prod"` + // FXSource — откуда брать курсы валют для оценки валютных выпусков: + // пусто/"cbr" — Банк России (открытый XML, без ключа), "accounting" — курс + // учётной системы (OD_VALUES_RATES), если сервер стоит без выхода в интернет. + FXSource string `json:"lk_fx_source,omitempty"` + // SnapshotMode — ежедневный срез стоимости портфелей (из него кабинет рисует график): + // пусто — автоматически (ведём, если настроен экспорт QUIK), "on" — всегда, "off" — не вести. + // SnapshotAt — время снятия среза, «ЧЧ:ММ» (пусто = 20:30, после закрытия торгов). + SnapshotMode string `json:"lk_snapshot_mode,omitempty"` + SnapshotAt string `json:"lk_snapshot_at,omitempty"` + // EmulateNSD — заявки ЛК проходят весь путь (сверка DEPO, блокировка QUIK, сделка, // статус в ЛК), но в НРД не уходят: отправку и ответ контрагента имитирует bj. // Для боевого ЛК при тестовом профиле ИШ имитация включается принудительно. diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index a0627c8..7f9b2c1 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -291,6 +291,8 @@ func (s *Server) Run(ctx context.Context) error { go superviseLoop(ctx, "lk-emulator", s.rc, s.consumeEmulatedDecisions) // Отказы шлюза по заявкам ЛК: ждём ответ Fancy (DEPO) и только тогда ставим «Отклонена» в ЛК. go superviseLoop(ctx, "lk-flow", s.rc, s.lkFlowLoop) + // Ежедневный срез стоимости портфелей: из него кабинет рисует график. + go superviseLoop(ctx, "lk-snapshot", s.rc, s.lkSnapshotLoop) // Кабинет клиента в режиме ожидания: ядро само подключается к кабинету на // хостинге и обслуживает запросы клиентов через исходящее соединение. diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index e024e34..21b2207 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -92,6 +92,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service mux.HandleFunc("/admin/setup/lk/users", h.lkUsersSave) mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload) mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave) + mux.HandleFunc("/admin/setup/lk/data", h.lkDataSave) mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration) mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn) mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate) @@ -322,6 +323,7 @@ type SetupData struct { LKUsers []LKUser // учётные записи кабинета LKUsersError string LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра + LKData LKDataView // курсы валют и ежедневный срез стоимости Versions SystemVersions // версии компонентов bj и внешних систем Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак) Features []LicenseFeatureView // признаки лицензии для карточки @@ -388,6 +390,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl QuikExport: quikExportView(h.rc, req.URL.Query().Get("reveal_ro") == "1"), LKAgent: lkAgentStatus(s), LKPreview: lkPreviewStatus(h.rc, req), + LKData: lkDataView(h.rc), LKKeyMask: lkKeyForPage(s.LK.APIKey), Versions: systemVersions(h.rc), Modules: modulesAllowed(h.rc), diff --git a/internal/lkgateway/web/templates/admin_setup.html b/internal/lkgateway/web/templates/admin_setup.html index afb7e2f..91a58e3 100644 --- a/internal/lkgateway/web/templates/admin_setup.html +++ b/internal/lkgateway/web/templates/admin_setup.html @@ -695,6 +695,44 @@ function hbBind(inp,box,list,onPick){
Вход — по учётным записям из карточки «Пользователи кабинета» ниже; сотрудник видит список клиентов, клиент — свой портфель. Данные те же, что получит кабинет на хостинге.
+Чем кабинет считает стоимость портфеля. Курс нужен для выпусков с валютным номиналом (замещающие и еврооблигации): цена по ним — процент от номинала в долларах, юанях, евро. История стоимости накапливается только у нас: ни торговая система, ни брокерский учёт её не хранят.
+ + +Учётные записи кабинета живут здесь, в базе ядра: кабинет на хостинге своей базы не имеет. Клиент привязан к номеру договора депозитария — по нему кабинет показывает портфель. Сотрудник («администратор кабинета») видит список клиентов и открывает их кабинеты на просмотр. Письма не отправляются: почта только хранится, пароль сообщите лично.
diff --git a/modules/lk/lk.go b/modules/lk/lk.go index 85fc5fb..dd2219f 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -72,6 +72,13 @@ func New(cfg Config) (*Server, error) { return strconv.FormatFloat(f, 'f', -1, 64) }, "fmtmoney": fmtMoney, + // доля координаты X в ширине холста — для подписей дат под графиком + "pctOf": func(x, w float64) float64 { + if w == 0 { + return 0 + } + return x / w * 100 + }, // Русское число: 1 договор, 2 договора, 5 договоров. "plural": func(n int, one, few, many string) string { w := many @@ -400,6 +407,9 @@ type portfolioResp struct { ReadOnly bool `json:"read_only"` TotalValue float64 `json:"total_value"` Priced int `json:"priced"` + Email string `json:"email"` + Phone string `json:"phone"` + LastLogin string `json:"last_login"` } // operationRow — движение по счёту депо. @@ -455,6 +465,110 @@ type lkUpcoming struct { Percent float64 `json:"percent"` } +// lkPoint — точка истории стоимости портфеля (ответ /api/lk/history). +type lkPoint struct { + Day string `json:"day"` + Value float64 `json:"value"` + Cash float64 `json:"cash"` + Total float64 `json:"total"` + Positions int `json:"positions"` +} + +// lkChart — готовый график для вставки в страницу: считаем его на сервере и +// отдаём координатами, потому что скриптов в кабинете нет (строгая CSP). +type lkChart struct { + Line, Area string // пути SVG + W, H float64 // размеры холста + Min, Max float64 // границы шкалы + First, Last float64 // стоимость в начале и в конце периода + Change float64 // изменение за период, ₽ + ChangePct float64 // изменение за период, % + FromDay string // первая дата периода + ToDay string // последняя дата + Points int + GridY []float64 // горизонтальные линии (y в координатах холста) + GridLabel []string // подписи к ним + Marks []lkMark // подписи дат под осью +} + +// lkMark — подпись даты на оси времени. +type lkMark struct { + X float64 + Text string +} + +// portfolioChart — линия стоимости портфеля по ежедневным срезам. +func portfolioChart(points []lkPoint) *lkChart { + if len(points) < 2 { + return nil + } + const w, h, pad = 980.0, 190.0, 10.0 + lo, hi := points[0].Total, points[0].Total + for _, p := range points { + if p.Total < lo { + lo = p.Total + } + if p.Total > hi { + hi = p.Total + } + } + // Ось не от нуля: иначе движение на 1–2 % не видно. Запас сверху и снизу — + // чтобы линия не липла к краю. + span := hi - lo + if span <= 0 { + span = hi * 0.02 + if span <= 0 { + span = 1 + } + } + lo, hi = lo-span*0.15, hi+span*0.15 + x := func(i int) float64 { return pad + (w-2*pad)*float64(i)/float64(len(points)-1) } + y := func(v float64) float64 { return pad + (h-2*pad)*(1-(v-lo)/(hi-lo)) } + var line, area strings.Builder + for i, p := range points { + cmd := "L" + if i == 0 { + cmd = "M" + } + fmt.Fprintf(&line, "%s%.1f %.1f ", cmd, x(i), y(p.Total)) + } + area.WriteString(line.String()) + fmt.Fprintf(&area, "L%.1f %.1f L%.1f %.1f Z", x(len(points)-1), h, x(0), h) + c := &lkChart{Line: strings.TrimSpace(line.String()), Area: area.String(), W: w, H: h, + Min: lo, Max: hi, First: points[0].Total, Last: points[len(points)-1].Total, + FromDay: ruDateFull(points[0].Day), ToDay: ruDateFull(points[len(points)-1].Day), Points: len(points)} + c.Change = c.Last - c.First + if c.First != 0 { + c.ChangePct = c.Change / c.First * 100 + } + for _, frac := range []float64{0, 0.5, 1} { + v := lo + (hi-lo)*frac + c.GridY = append(c.GridY, y(v)) + c.GridLabel = append(c.GridLabel, fmtMoney(v)) + } + // Подписи дат: начало, середина, конец — больше на узкой карточке не влезает. + for _, i := range []int{0, len(points) / 2, len(points) - 1} { + c.Marks = append(c.Marks, lkMark{X: x(i), Text: ruDate(points[i].Day)}) + } + return c +} + +// ruDateFull — 2026-09-18 → 18.09.2026. +func ruDateFull(iso string) string { + if t, err := time.Parse("2006-01-02", iso); err == nil { + return t.Format("02.01.2006") + } + return iso +} + +// ruDate — 2026-09-18 → 18.09. +func ruDate(iso string) string { + if t, err := time.Parse("2006-01-02", iso); err == nil { + return t.Format("02.01") + } + return iso +} + // lkSegment — доля вида активов в портфеле для полосы структуры. type lkSegment struct { Kind string @@ -810,6 +924,20 @@ func (s *Server) buildHandler() http.Handler { upTotal += u.Total } data["UpcomingTotal"] = upTotal + // График стоимости — только для «Онлайн»: в архиве он был бы историей + // срезов, а не тем, что показано на экране. + if view != "archive" { + var hist struct { + Points []lkPoint `json:"points"` + On bool `json:"on"` + At string `json:"at"` + } + if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/history?days=180", sess, nil, &hist); err == nil { + data["Chart"] = portfolioChart(hist.Points) + data["ChartPoints"] = len(hist.Points) + data["ChartOn"] = hist.On + } + } s.render(w, r, "portfolio", data) }) @@ -892,39 +1020,16 @@ func (s *Server) buildHandler() http.Handler { http.Redirect(w, r, "/login", http.StatusSeeOther) return } - if r.Method == http.MethodPost { - _ = r.ParseForm() - var res struct { - Message string `json:"message"` - } - err := s.bj().call(reqCtx(r), http.MethodPost, "/api/lk/documents", sess, map[string]string{ - "kind": r.FormValue("kind"), - "from": r.FormValue("from"), - "to": r.FormValue("to"), - "account": r.FormValue("account"), - "comment": strings.TrimSpace(r.FormValue("comment")), - }, &res) - if err != nil { - setFlashCookie(w, r, err.Error()) - http.Redirect(w, r, "/documents", http.StatusSeeOther) - return - } - setFlashCookie(w, r, res.Message) - http.Redirect(w, r, "/documents", http.StatusSeeOther) - return - } var out struct { - Ready []documentRow `json:"ready"` - Requests []documentRow `json:"requests"` - Kinds []string `json:"kinds"` + Ready []documentRow `json:"ready"` } if err := s.bj().call(reqCtx(r), http.MethodGet, "/api/lk/documents", sess, nil, &out); err != nil { s.render(w, r, "documents", map[string]any{"Nav": "d", "Error": err.Error()}) return } - s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready, "Requests": out.Requests, - "Kinds": out.Kinds, // форма заказа в клиентском виде скрыта; данные оставлены для API - "From": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), "To": time.Now().Format("2006-01-02")}) + // Заказ документов через кабинет убран: выписки готовит депозитарий, + // клиенту показываем только готовое. + s.render(w, r, "documents", map[string]any{"Nav": "d", "Ready": out.Ready}) }) // Профиль: договор, счета депо, доступ @@ -950,6 +1055,7 @@ func (s *Server) buildHandler() http.Handler { 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, + "Email": pf.Email, "Phone": pf.Phone, "LastLogin": pf.LastLogin, }) }) diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index f39a1c7..e7cb9b9 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -33,7 +33,7 @@ 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", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "positions": []any{ + _ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "email": "client@example.org", "phone": "+7 900 000-00-00", "last_login": "17.09.2026 21:58", "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", "price": 275.67, "value": 110268, "change_pct": -1.53}, map[string]any{"isin": "US0378331005", "security_name": "Apple Inc.", "account_id": "В000000044", "quantity": 12, "blocked": 0, "available": 0, "transferable": false, "note": "иностранная бумага — перевод через M2M недоступен"}, }}) @@ -47,6 +47,15 @@ func fakeBJ(t *testing.T) *httptest.Server { }}) } }) + mux.HandleFunc("/api/lk/history", 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{"on": true, "at": "20:30", "points": []any{ + map[string]any{"day": "2026-09-16", "value": 100000.0, "cash": 200.0, "total": 100200.0}, + map[string]any{"day": "2026-09-17", "value": 104000.0, "cash": 200.0, "total": 104200.0}, + map[string]any{"day": "2026-09-18", "value": 110068.0, "cash": 200.0, "total": 110268.0}, + }}) + } + }) mux.HandleFunc("/api/lk/admin/clients", func(w http.ResponseWriter, r *http.Request) { if !keyOK(w, r) || r.Header.Get("X-BJ-Session") != "sess-1" { return @@ -561,3 +570,47 @@ func TestClientsSearch(t *testing.T) { t.Errorf("пустой результат не объяснён: %s", empty) } } + +// График стоимости и контакты в профиле. +func TestChartAndProfile(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{"Стоимость портфеля", "График стоимости появится, когда накопятся ежедневные срезы: сейчас их {{.ChartPoints}}.
{{end}}{{end}} + {{if .Structure}}Контакты, паспортные данные и реквизиты для выплат меняет депозитарий: обратитесь к своему менеджеру, изменения появятся в кабинете после внесения в анкету.
Так с вами свяжется депозитарий. Если данные устарели — сообщите менеджеру: из кабинета мы их не меняем.
+