diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index febc07e..8c323f5 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -236,15 +236,63 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { } pos = p2 } + total, priced := lkApplyPrices(r.Context(), rc, pos) lkJSON(w, http.StatusOK, map[string]any{ - "contract": u.Contract, - "full_name": u.FullName, - "positions": pos, - "source": source, - "read_only": readOnly, + "contract": u.Contract, + "full_name": u.FullName, + "positions": pos, + "source": source, + "read_only": readOnly, + "total_value": total, // оценка портфеля, 0 — цен нет + "priced": priced, // по скольким бумагам есть цена }) }) + // Карточка бумаги: реквизиты выпуска, цена, позиция клиента, график выплат. + mux.HandleFunc("/api/lk/security", func(w http.ResponseWriter, r *http.Request) { + if !auth(w, r) { + return + } + u, ok := current(w, r) + if !ok { + return + } + isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin"))) + if isin == "" { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": "не указана бумага"}) + return + } + var pos *ClientPosition + if dp, err := depoClientPositions(r.Context(), rc.Snapshot().Fancy, u.Contract); err == nil && len(dp) > 0 { + for _, p := range depoPositionsAsClient(dp) { + if strings.EqualFold(p.ISIN, isin) { + cp := p + pos = &cp + break + } + } + } else if p2, err2 := lkClientPositions(r.Context(), rc, u.Contract); err2 == nil { + for _, p := range markTransferable(p2) { + if strings.EqualFold(p.ISIN, isin) { + cp := p + pos = &cp + break + } + } + } + if pos != nil { + one := []ClientPosition{*pos} + lkApplyPrices(r.Context(), rc, one) + pos = &one[0] + } + card, err := securityCard(r.Context(), rc, isin, pos) + if err != nil { + lkJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + lkJSON(w, http.StatusOK, map[string]any{"security": card}) + }) + mux.HandleFunc("/api/lk/claims", func(w http.ResponseWriter, r *http.Request) { if !auth(w, r) { return diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go index f7edb7d..ed6f16a 100644 --- a/internal/lkgateway/lkstore.go +++ b/internal/lkgateway/lkstore.go @@ -131,6 +131,11 @@ type ClientPosition struct { // Transferable — бумагу можно перевести через M2M (только российские выпуски). Transferable bool `json:"transferable"` Note string `json:"note"` + // Оценка по данным торговой системы (пакет «Цены»); 0 — цены нет. + Price float64 `json:"price,omitempty"` + PriceAt string `json:"price_board,omitempty"` // класс торгов, откуда цена + Value float64 `json:"value,omitempty"` // стоимость позиции + ChangePct float64 `json:"change_pct,omitempty"` // изменение цены к закрытию, % } // lkClientPositions — позиции клиента модуля ЛК по номеру договора (тестовые клиенты; diff --git a/internal/lkgateway/prices.go b/internal/lkgateway/prices.go new file mode 100644 index 0000000..70501dc --- /dev/null +++ b/internal/lkgateway/prices.go @@ -0,0 +1,151 @@ +package lkgateway + +// prices.go — рыночные цены бумаг из базы экспорта QUIK (таблицы Securities и Params, +// пакет данных «Цены для оценки портфеля»). Нужны кабинету, чтобы показать стоимость +// позиции и портфеля. Подключение берём из настроек «НПР / УДС» — это та же база +// экспорта; без неё кабинет просто не показывает оценку. +// +// Цена облигации приходит в процентах от номинала, акции и пая — в рублях за штуку. +// Оценка: облигация = количество × номинал × цена / 100 + НКД × количество. + +import ( + "context" + "strings" + "sync" + "time" +) + +// SecurityPrice — цена одной бумаги на момент среза. +type SecurityPrice struct { + ISIN string `json:"isin"` + Board string `json:"board"` // класс QUIK, откуда взята цена + Name string `json:"name,omitempty"` // краткое наименование в торговой системе + Price float64 `json:"price"` // последняя цена (для облигаций — процент номинала) + Prev float64 `json:"prev,omitempty"` // цена предыдущего закрытия + Face float64 `json:"face,omitempty"` // номинал + Accrued float64 `json:"accrued,omitempty"` // НКД на бумагу + InPct bool `json:"in_pct,omitempty"` // цена в процентах от номинала + ChangePc float64 `json:"change_pc,omitempty"` // изменение к закрытию, % +} + +// Value — стоимость количества бумаг по этой цене. +func (p SecurityPrice) Value(qty float64) float64 { + if p.Price <= 0 || qty == 0 { + return 0 + } + if p.InPct && p.Face > 0 { + return qty*p.Face*p.Price/100 + qty*p.Accrued + } + return qty * p.Price +} + +// pricesCache — цены живут минуту: страницу кабинета открывают часто, а цикл экспорта — 30 с. +var ( + priceMu sync.Mutex + priceAt time.Time + priceLast map[string]SecurityPrice +) + +// lkPrices — цены по списку ISIN. Пустая карта, если база экспорта не настроена или +// пакет «Цены» не включён: кабинет в этом случае показывает реестр без оценки. +func lkPrices(ctx context.Context, rc *RuntimeConfig, isins []string) map[string]SecurityPrice { + out := map[string]SecurityPrice{} + if rc == nil || len(isins) == 0 { + return out + } + priceMu.Lock() + if time.Since(priceAt) < time.Minute && priceLast != nil { + for _, isin := range isins { + if p, ok := priceLast[strings.ToUpper(isin)]; ok { + out[p.ISIN] = p + } + } + priceMu.Unlock() + return out + } + priceMu.Unlock() + + p, err := nprDB(rc) + if err != nil { + return out + } + cctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + // Одна цена на бумагу: берём класс основных торгов (акции TQBR, ОФЗ TQOB, + // корпоративные облигации TQCB, фонды TQTF/TQIF), иначе — класс с торгами. + rows, err := p.Query(cctx, ` + SELECT DISTINCT ON (s."ISINCode") + upper(s."ISINCode"), s."ClassCode", COALESCE(s."SecShortName", s."SecName", ''), + COALESCE(p.last, 0)::float8, COALESCE(p.prevprice, 0)::float8, + COALESCE(s."FaceValue", 0)::float8, COALESCE(p.accruedint, 0)::float8 + FROM "Securities" s JOIN "Params" p ON p."SecCode" = s."SecCode" AND p."ClassCode" = s."ClassCode" + WHERE s."ISINCode" <> '' AND (p.last > 0 OR p.prevprice > 0) + ORDER BY s."ISINCode", + CASE s."ClassCode" WHEN 'TQBR' THEN 1 WHEN 'TQOB' THEN 2 WHEN 'TQCB' THEN 3 + WHEN 'TQTF' THEN 4 WHEN 'TQIF' THEN 5 WHEN 'TQRD' THEN 6 ELSE 9 END, + p.last DESC`) + if err != nil { + return out + } + all := map[string]SecurityPrice{} + for rows.Next() { + var sp SecurityPrice + if rows.Scan(&sp.ISIN, &sp.Board, &sp.Name, &sp.Price, &sp.Prev, &sp.Face, &sp.Accrued) != nil { + continue + } + if sp.Price <= 0 { + sp.Price = sp.Prev + } + sp.InPct = sp.Face > 0 && (sp.Board == "TQOB" || sp.Board == "TQCB" || strings.HasSuffix(sp.Board, "_BND")) + if sp.Prev > 0 && sp.Price > 0 { + sp.ChangePc = (sp.Price - sp.Prev) / sp.Prev * 100 + } + all[sp.ISIN] = sp + } + rows.Close() + if len(all) == 0 { + return out + } + priceMu.Lock() + priceLast, priceAt = all, time.Now() + priceMu.Unlock() + for _, isin := range isins { + if sp, ok := all[strings.ToUpper(isin)]; ok { + out[sp.ISIN] = sp + } + } + return out +} + +// lkPriceReset — сбросить кэш цен (после смены настроек подключения). +func lkPriceReset() { + priceMu.Lock() + priceLast, priceAt = nil, time.Time{} + priceMu.Unlock() +} + +// lkApplyPrices — проставляет позициям цену и стоимость, возвращает оценку портфеля +// и число бумаг, по которым цена нашлась. +func lkApplyPrices(ctx context.Context, rc *RuntimeConfig, pos []ClientPosition) (total float64, priced int) { + if len(pos) == 0 { + return 0, 0 + } + isins := make([]string, 0, len(pos)) + for _, p := range pos { + if p.ISIN != "" { + isins = append(isins, p.ISIN) + } + } + px := lkPrices(ctx, rc, isins) + for i := range pos { + sp, ok := px[strings.ToUpper(pos[i].ISIN)] + if !ok || sp.Price <= 0 { + continue + } + pos[i].Price, pos[i].PriceAt, pos[i].ChangePct = sp.Price, sp.Board, sp.ChangePc + pos[i].Value = sp.Value(pos[i].Quantity) + total += pos[i].Value + priced++ + } + return total, priced +} diff --git a/internal/lkgateway/prices_live_test.go b/internal/lkgateway/prices_live_test.go new file mode 100644 index 0000000..56d9212 --- /dev/null +++ b/internal/lkgateway/prices_live_test.go @@ -0,0 +1,39 @@ +//go:build live + +package lkgateway + +import ( + "context" + "testing" +) + +// Цены из базы экспорта QUIK и карточка бумаги из справочника депозитария. +func TestLivePricesAndCard(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + ctx := context.Background() + isins := []string{"RU0009029540", "RU000A1014L8", "RU000A10EYV6", "RU000A1038V6"} + px := lkPrices(ctx, rc, isins) + if len(px) == 0 { + t.Fatal("цены не получены: проверьте подключение к базе экспорта и пакет «Цены»") + } + for _, i := range isins { + p := px[i] + t.Logf("%s: %s %.4f (пред. %.4f, номинал %.2f, НКД %.4f, %%=%v) → 100 шт. = %.2f ₽", + i, p.Board, p.Price, p.Prev, p.Face, p.Accrued, p.InPct, p.Value(100)) + } + for _, i := range []string{"RU000A10EYV6", "RU0009029540"} { + card, err := securityCard(ctx, rc, i, nil) + if err != nil { + t.Fatalf("карточка %s: %v", i, err) + } + t.Logf("%s · %s · %s · эмитент %q · номинал %.2f · погашение %s · CFI %s · на обслуживании %v · квал %v · выплат %d", + card.ISIN, card.Name, card.Kind, card.Issuer, card.Face, card.Maturity, card.CFI, card.Accepted, card.QualOnly, len(card.Payments)) + for _, p := range card.Payments { + t.Logf(" %s %s %.2f %.2f%%", p.Date, p.Kind, p.Amount, p.Percent) + } + } +} diff --git a/internal/lkgateway/security_card.go b/internal/lkgateway/security_card.go new file mode 100644 index 0000000..aa890dc --- /dev/null +++ b/internal/lkgateway/security_card.go @@ -0,0 +1,180 @@ +package lkgateway + +// security_card.go — карточка ценной бумаги для кабинета клиента: реквизиты выпуска из +// справочника депозитария (OD_VALUES + действующая версия OD_SHARES), график купонов и +// оферт (OD_COUPONS, OD_OFFERS), цена из базы экспорта QUIK и позиция клиента. + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// SecurityPayment — событие по бумаге: купон, оферта, погашение. +type SecurityPayment struct { + Date string `json:"date"` // ДД.ММ.ГГГГ + Kind string `json:"kind"` // купон | оферта | погашение + Amount float64 `json:"amount,omitempty"` // на одну бумагу + Percent float64 `json:"percent,omitempty"` // ставка купона, % годовых + FixDate string `json:"fix_date,omitempty"` + Paid bool `json:"paid,omitempty"` +} + +// SecurityCard — карточка бумаги. +type SecurityCard struct { + ISIN string `json:"isin"` + Name string `json:"name"` + 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"` // номинал + Maturity string `json:"maturity,omitempty"` // дата погашения + Accepted bool `json:"accepted"` // принята на обслуживание + QualOnly bool `json:"qual_only,omitempty"` // только для квалифицированных инвесторов + QualNote string `json:"qual_note,omitempty"` + + Price float64 `json:"price,omitempty"` + PriceIn string `json:"price_in,omitempty"` // «₽ за штуку» | «% от номинала» + Board string `json:"board,omitempty"` // класс торгов + ChangePct float64 `json:"change_pct,omitempty"` // изменение к закрытию + + Position *ClientPosition `json:"position,omitempty"` + Payments []SecurityPayment `json:"payments,omitempty"` + Note string `json:"note,omitempty"` // почему чего-то нет +} + +// securityCard — собирает карточку. Позиция клиента передаётся снаружи (её уже знает +// обработчик портфеля), справочные данные читаются из учётной системы. +func securityCard(ctx context.Context, rc *RuntimeConfig, isin string, pos *ClientPosition) (SecurityCard, error) { + isin = strings.ToUpper(strings.TrimSpace(isin)) + card := SecurityCard{ISIN: isin, Position: pos} + if pos != nil { + card.Name = pos.SecurityName + } + if isin == "" { + return card, fmt.Errorf("не указана бумага") + } + if px := lkPrices(ctx, rc, []string{isin}); len(px) > 0 { + p := px[isin] + card.Price, card.Board, card.ChangePct = p.Price, p.Board, p.ChangePc + card.PriceIn = "₽ за штуку" + if p.InPct { + card.PriceIn = "% от номинала" + } + if card.Name == "" { + card.Name = p.Name + } + } + + fs := rc.Snapshot().Fancy + if strings.TrimSpace(fs.Server) == "" { + card.Note = "справочник депозитария недоступен" + return card, nil + } + db, dctx, cancel, errS := depoOpen(fs) + if errS != "" { + card.Note = errS + return card, nil + } + defer cancel() + defer db.Close() + _ = dctx + + var valID int64 + var name, cfi string + var vType, isIn sql.NullInt64 + err := db.QueryRowContext(dctx, `SELECT TOP 1 ID, ISNULL(NAME,''), ISNULL(CFI,''), V_TYPE, IS_IN FROM OD_VALUES WHERE ISIN=@p1 ORDER BY ID`, + sql.Named("p1", isin)).Scan(&valID, &name, &cfi, &vType, &isIn) + if err != nil { + card.Note = "бумаги нет в справочнике депозитария" + return card, nil + } + if name != "" { + card.Name = name + } + card.CFI, card.Accepted = cfi, isIn.Valid && isIn.Int64 == 1 + card.Kind = securityKind(cfi) + + var issuer sql.NullInt64 + var nominal sql.NullFloat64 + var regNum sql.NullString + var death sql.NullTime + err = db.QueryRowContext(dctx, `SELECT TOP 1 ISSUER, CAST(NOMINAL AS float), NUM_REG, DEATHDATE FROM OD_SHARES + WHERE ID=@p1 AND E_DATE > GETDATE() ORDER BY B_DATE DESC`, sql.Named("p1", valID)).Scan(&issuer, &nominal, ®Num, &death) + if err == nil { + card.Face = nominal.Float64 + card.RegNumber = strings.TrimSpace(regNum.String) + if death.Valid && death.Time.Year() < 9000 { + card.Maturity = death.Time.Format("02.01.2006") + } + if issuer.Valid && issuer.Int64 > 0 { + var iname string + if db.QueryRowContext(dctx, `SELECT TOP 1 ISNULL(NAME,'') FROM OD_FACES WHERE ID=@p1 OR SELF_ID=@p1 ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`, + sql.Named("p1", issuer.Int64)).Scan(&iname) == nil { + card.Issuer = strings.TrimSpace(iname) + } + } + } + card.QualOnly, card.QualNote = depoSecurityQualOnly(dctx, db, valID) + + // График: купоны за полгода назад и вперёд, ближайшие оферты, погашение. + // Дата выплаты купона: P_DATE, а пока купон не выплачен — конец купонного периода. + rows, err := db.QueryContext(dctx, `SELECT TOP 12 ISNULL(P_DATE, E_DATE) AS PAY_DATE, CAST(ISNULL(SUMMA,0) AS float), + CAST(ISNULL(PERCENT_,0) AS float), FIX_DATE, ISNULL(IS_PAY,0) + FROM OD_COUPONS WHERE SHARE=@p1 AND ISNULL(P_DATE, E_DATE) >= DATEADD(month,-3,GETDATE()) + ORDER BY PAY_DATE`, sql.Named("p1", valID)) + if err == nil { + for rows.Next() { + var pd, fx sql.NullTime + var summa, pct float64 + var isPay sql.NullInt64 + if rows.Scan(&pd, &summa, &pct, &fx, &isPay) != nil || !pd.Valid { + continue + } + p := SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "купон", Amount: summa, Percent: pct, + Paid: pd.Time.Before(time.Now())} + if fx.Valid && fx.Time.Year() < 9000 { + p.FixDate = fx.Time.Format("02.01.2006") + } + card.Payments = append(card.Payments, p) + } + rows.Close() + } + orows, err := db.QueryContext(dctx, `SELECT TOP 3 P_DATE, CAST(ISNULL(O_PRICE,0) AS float) FROM OD_OFFERS + WHERE SHARE=@p1 AND CANCEL_DATE IS NULL AND P_DATE >= GETDATE() ORDER BY P_DATE`, sql.Named("p1", valID)) + if err == nil { + for orows.Next() { + var pd sql.NullTime + var price float64 + if orows.Scan(&pd, &price) == nil && pd.Valid { + card.Payments = append(card.Payments, SecurityPayment{Date: pd.Time.Format("02.01.2006"), Kind: "оферта", Amount: price}) + } + } + orows.Close() + } + if card.Maturity != "" { + card.Payments = append(card.Payments, SecurityPayment{Date: card.Maturity, Kind: "погашение", Amount: card.Face}) + } + return card, nil +} + +// securityKind — вид бумаги по коду CFI (первая буква классификации). +func securityKind(cfi string) string { + if cfi == "" { + return "" + } + switch strings.ToUpper(cfi[:1]) { + case "E": + return "акция" + case "D": + return "облигация" + case "C": + return "пай фонда" + case "R": + return "право" + } + return "ценная бумага" +} diff --git a/modules/lk/lk.go b/modules/lk/lk.go index 2d54e8c..d84116f 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -53,6 +53,40 @@ func New(cfg Config) (*Server, error) { } return strconv.FormatFloat(f, 'f', -1, 64) }, + "fmtmoney": func(f float64) string { // 1 234 567,89 — с разделителем разрядов + neg := f < 0 + if neg { + f = -f + } + whole := int64(f) + cents := int64((f-float64(whole))*100 + 0.5) + if cents >= 100 { + whole, cents = whole+1, 0 + } + d := strconv.FormatInt(whole, 10) + var b strings.Builder + for i, c := range d { + if i > 0 && (len(d)-i)%3 == 0 { + b.WriteByte(' ') + } + b.WriteRune(c) + } + out := b.String() + if cents > 0 { + out += "," + fmt.Sprintf("%02d", cents) + } + if neg { + out = "−" + out + } + return out + }, + "fmtpct": func(f float64) string { + s := strconv.FormatFloat(f, 'f', 2, 64) + if f > 0 { + s = "+" + s + } + return strings.ReplaceAll(s, ".", ",") + " %" + }, "shortid": func(s string) string { if len(s) > 8 { return s[:8] + "…" @@ -126,6 +160,9 @@ type position struct { Available float64 `json:"available"` UpdatedAt string `json:"updated_at"` Transferable bool `json:"transferable"` + Price float64 `json:"price"` + Value float64 `json:"value"` + ChangePct float64 `json:"change_pct"` Note string `json:"note"` } @@ -175,11 +212,42 @@ func claimsView(rows []claimRow) (active, done int) { // portfolioResp — ответ /api/lk/portfolio. type portfolioResp struct { - Contract string `json:"contract"` - FullName string `json:"full_name"` - Positions []position `json:"positions"` - Source string `json:"source"` - ReadOnly bool `json:"read_only"` + Contract string `json:"contract"` + FullName string `json:"full_name"` + Positions []position `json:"positions"` + Source string `json:"source"` + ReadOnly bool `json:"read_only"` + TotalValue float64 `json:"total_value"` + Priced int `json:"priced"` +} + +// securityCard — карточка бумаги из ядра. +type securityCard struct { + ISIN string `json:"isin"` + Name string `json:"name"` + Kind string `json:"kind"` + Issuer string `json:"issuer"` + RegNumber string `json:"reg_number"` + CFI string `json:"cfi"` + Face float64 `json:"face"` + Maturity string `json:"maturity"` + Accepted bool `json:"accepted"` + QualOnly bool `json:"qual_only"` + QualNote string `json:"qual_note"` + Price float64 `json:"price"` + PriceIn string `json:"price_in"` + Board string `json:"board"` + ChangePct float64 `json:"change_pct"` + Position *position `json:"position"` + Payments []struct { + Date string `json:"date"` + Kind string `json:"kind"` + Amount float64 `json:"amount"` + Percent float64 `json:"percent"` + FixDate string `json:"fix_date"` + Paid bool `json:"paid"` + } `json:"payments"` + Note string `json:"note"` } // portfolio — реестр клиента из ядра. @@ -324,6 +392,7 @@ func (s *Server) Handler() http.Handler { "Nav": "b", "Contract": pf.Contract, "FullName": pf.FullName, "Positions": pos, "Claims": claims, "Flash": r.URL.Query().Get("m"), "Total": total, "Free": free, "Blocked": blocked, "Active": active, + "Value": pf.TotalValue, "Priced": pf.Priced, }) }) @@ -349,9 +418,32 @@ func (s *Server) Handler() http.Handler { "Positions": pf.Positions, "Flash": r.URL.Query().Get("m"), "Source": pf.Source, "ReadOnly": pf.ReadOnly, "UpdatedAt": updated, "Total": total, "Free": free, "Blocked": blocked, + "Value": pf.TotalValue, "Priced": pf.Priced, }) }) + // Карточка бумаги + mux.HandleFunc("/security", func(w http.ResponseWriter, r *http.Request) { + sess := sessionW(w, r) + if sess == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + isin := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("isin"))) + if isin == "" { + http.Redirect(w, r, "/portfolio", http.StatusSeeOther) + return + } + var out struct { + Security securityCard `json:"security"` + } + if err := bj.call(r.Context(), http.MethodGet, "/api/lk/security?isin="+url.QueryEscape(isin), sess, nil, &out); err != nil { + s.render(w, r, "security", map[string]any{"Nav": "p", "Error": err.Error(), "ISIN": isin}) + return + } + s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin}) + }) + // Профиль: договор, счета депо, доступ mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) { sess := sessionW(w, r) diff --git a/modules/lk/lk_test.go b/modules/lk/lk_test.go index c2463cf..9678449 100644 --- a/modules/lk/lk_test.go +++ b/modules/lk/lk_test.go @@ -28,8 +28,8 @@ 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": "депозитарный учёт", "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"}, + _ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "source": "депозитарный учёт", "total_value": 110268.0, "priced": 1, "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 недоступен"}, }}) } @@ -42,6 +42,17 @@ func fakeBJ(t *testing.T) *httptest.Server { }}) } }) + mux.HandleFunc("/api/lk/security", 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{"security": map[string]any{ + "isin": "RU0009029540", "name": "Сбербанк РФ-АО3", "kind": "акция", "issuer": "ПАО Сбербанк", + "reg_number": "10301481B", "cfi": "ESVXFR", "face": 3, "accepted": true, "price": 275.67, "price_in": "₽ за штуку", + "board": "TQBR", "change_pct": -1.53, + "position": map[string]any{"isin": "RU0009029540", "quantity": 400, "available": 300, "value": 110268, "transferable": true}, + "payments": []any{map[string]any{"date": "18.10.2026", "kind": "дивиденды", "amount": 34.5}}, + }}) + } + }) mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) { if keyOK(w, r) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) @@ -154,11 +165,14 @@ func TestBoardPages(t *testing.T) { return rec.Body.String() } board := get("/") - for _, want := range []string{"Бумаг в реестре", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} { + for _, want := range []string{"Стоимость бумаг", "Новое поручение", "Сбербанк, ао", "Отправлено в НРД", "Поручений в работе"} { if !strings.Contains(board, want) { t.Errorf("на табло нет %q", want) } } + if !strings.Contains(board, "110 268 ₽") { + t.Errorf("на табло нет оценки портфеля") + } reg := get("/portfolio") for _, want := range []string{"ВТНКЦ00000001", "иностранный выпуск", "депозитарный учёт", "перевести"} { if !strings.Contains(reg, want) { @@ -175,6 +189,12 @@ func TestBoardPages(t *testing.T) { t.Errorf("в бланке поручения нет %q", want) } } + card := get("/security?isin=RU0009029540") + for _, want := range []string{"ПАО Сбербанк", "Код CFI", "275,67", "дивиденды", "Перевести эту бумагу"} { + if !strings.Contains(card, want) { + t.Errorf("в карточке бумаги нет %q", want) + } + } prof := get("/profile") if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") { t.Errorf("профиль: %s", prof) diff --git a/modules/lk/templates.go b/modules/lk/templates.go index f7937a4..ac69c0d 100644 --- a/modules/lk/templates.go +++ b/modules/lk/templates.go @@ -88,6 +88,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} @media(max-width:600px){main{padding:18px 14px 28px}.board{padding:18px 14px}.board .big{font-size:32px}.top{padding:0 14px}.sheet{padding:16px 14px}}
{{end}} +{{define "money"}}{{if gt . 0.0}}{{fmtmoney .}} ₽{{else}}—{{end}}{{end}} + {{define "mark"}}{{end}} {{define "nav"}}| Выпуск | Счёт депо | Количество | Свободно | Состояние | ||
|---|---|---|---|---|---|---|
| Выпуск | Счёт депо | Количество | Свободно | Оценка | Состояние | |
| {{.SecurityName}} {{.ISIN}} |
+ {{.SecurityName}} {{.ISIN}} |
{{if .AccountID}}{{.AccountID}}{{else}}—{{end}} | {{fmtnum .Quantity}} | {{fmtnum .Available}} | +{{template "money" .Value}} | {{if not .Transferable}}не переводится{{else if gt .Blocked 0.0}}{{fmtnum .Blocked}} с ограничением{{else}}свободно{{end}} |
Бумаг на счетах депо не найдено. Если это ошибка — напишите в депозитарий.
{{end}} @@ -183,7 +186,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px} {{define "portfolio"}}{{template "head" .}}{{template "nav" .}}| Выпуск | Счёт депо | Количество | С ограничением | Свободно | Состояние | ||||
|---|---|---|---|---|---|---|---|---|---|
| Выпуск | Счёт депо | Количество | С ограничением | Свободно | Цена | Оценка | Состояние | ||
| {{.SecurityName}} {{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}} |
+ {{.SecurityName}} {{.ISIN}}{{with .SecurityCode}} · {{.}}{{end}} |
{{if .AccountID}}{{.AccountID}}{{else}}—{{end}} | {{fmtnum .Quantity}} | {{if gt .Blocked 0.0}}{{fmtnum .Blocked}}{{else}}—{{end}} | {{fmtnum .Available}} | +{{if gt .Price 0.0}}{{fmtmoney .Price}}{{if ne .ChangePct 0.0}} {{fmtpct .ChangePct}} {{end}}{{else}}—{{end}} |
+ {{template "money" .Value}} | {{if not .Transferable}}иностранный выпуск{{else if gt .Blocked 0.0}}частично ограничено{{else}}свободно{{end}}{{with .Note}} {{.}} {{end}} |
{{if and .Transferable (gt .Available 0.0) (not $.ReadOnly)}}перевести{{end}} |
В кабинете клиента сотрудник готовит поручение, подписывает его клиент. Каждый вход сотрудника записывается в журнал.