Дашборд: переводы обоих направлений, переключатель «Сегодня / Всего»

На 9 при одном входящем переводе (Витвицкий) дашборд показывал «Переводов
ещё нет»: сводка и «Последние переводы» считали только исходящие, и только
последние 200.

- Сводка по направлениям: исходящие и входящие — всего, исполнено, в работе,
  отказ / истёк срок; исход — по состоянию «чего ждёт заявка» (livestatus.go),
  то же, что в журнале: принятый, но не исполненный перевод — «в работе».
- Период «Сегодня / Всего» (?period=, выбор помнится в куке; по умолчанию —
  сегодня, с 00:00 МСК). Плитка «Переводы» ведёт в журнал, счётчики за всё время.
- «Последние переводы» — общая лента журнала (без анкет участника): направление,
  инвестор, контрагент, статус и строка состояния.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-23 15:19:13 +03:00
parent a0ffbfa721
commit ffd69b0698
4 changed files with 202 additions and 59 deletions
+70 -27
View File
@@ -503,13 +503,11 @@ var globalRC *RuntimeConfig
type homeData struct {
page
Status SystemStatus
Counts struct {
Total int
Confirmed int
InProgress int
Failed int
}
Recent []ClaimView
// Сводка по переводам обоих направлений (23.10.2026: считались только исходящие, последние 200).
Period string // today | all — выбранный период сводки
Sum homePeriod // за выбранный период
All homePeriod // за всё время (плитка «Журнал»)
Recent []journalRow
News []NewsItem // top-3 активных или свежих новостей
// Сводка готовности системы для hero-блока дашборда.
@@ -731,21 +729,80 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
return a, nil
}
// homeDir — сводка дашборда по одному направлению: исход — по состоянию «чего ждёт заявка»
// (livestatus.go): в работе, исполнено (в т. ч. частично), отказ / истёк срок.
type homeDir struct{ Total, Done, InProgress, Failed int }
func (d *homeDir) add(st LiveStatus) {
d.Total++
switch {
case st.Active:
d.InProgress++
case st.Kind == "bad":
d.Failed++
case st.Kind == "ok" || st.Kind == "warn":
d.Done++
default:
d.InProgress++
}
}
type homePeriod struct{ Out, In homeDir }
// homePeriodOf — период сводки: ?period=today|all, выбор помнится в куке; по умолчанию — сегодня.
func homePeriodOf(w http.ResponseWriter, r *http.Request) string {
p := r.URL.Query().Get("period")
if p == "today" || p == "all" {
http.SetCookie(w, &http.Cookie{Name: "bj_home_period", Value: p, Path: "/admin", MaxAge: 365 * 24 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode})
return p
}
if c, err := r.Cookie("bj_home_period"); err == nil && c.Value == "all" {
return "all"
}
return "today"
}
// homeSummary — сводка и последние переводы (исходящие + входящие, без анкет участника) за период.
func homeSummary(ctx context.Context, svc *Service, period string) (sum, all homePeriod, recent []journalRow) {
store := globalIncomingStore
if store == nil {
store = &IncomingStore{}
}
n := time.Now().In(mskZone)
today := time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, mskZone)
for _, row := range buildJournalRows(ctx, svc, store, time.Time{}) {
if row.DirKind != "out" && row.DirKind != "in" || strings.HasPrefix(row.LiveKey, "pf:") {
continue
}
isToday := !row.Time.Before(today)
for _, p := range []*homePeriod{&all, &sum} {
if p == &sum && period == "today" && !isToday {
continue
}
if row.DirKind == "out" {
p.Out.add(row.Live)
} else {
p.In.add(row.Live)
}
}
if len(recent) < 10 && (period == "all" || isToday) {
recent = append(recent, row)
}
}
return sum, all, recent
}
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
ctx := r.Context()
status := CheckAll(ctx, opts)
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := homeData{
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
Period: homePeriodOf(w, r),
News: topNews(visibleNews(rc, rc.Snapshot().News.Items), 3),
LKUrl: rc.Snapshot().LK.PublicURL,
}
data.Sum, data.All, data.Recent = homeSummary(ctx, svc, data.Period)
data.NSDEmuOn, _ = lkEmulateNSD(rc)
data.QuikEmuOn = rc.Snapshot().Quik.Emulate && devMode(rc)
if q := rc.Snapshot().Quik; q.RealWrite && devMode(rc) {
@@ -760,20 +817,6 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
}
}
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
for _, c := range full.Items {
data.Counts.Total++
switch c.Status {
case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone):
data.Counts.Confirmed++
case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut):
data.Counts.Failed++
default:
data.Counts.InProgress++
}
}
}
render(w, a.home, data)
}
+76
View File
@@ -0,0 +1,76 @@
package lkgateway
import (
"context"
"net/http/httptest"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// Дашборд: исходящие и входящие, за сегодня и за всё время (23.10.2026: считались только исходящие —
// на 9 при одном входящем было «Переводов ещё нет»).
func TestHomeSummary(t *testing.T) {
ctx := context.Background()
repo := m2mcore.NewMemoryRepository()
svc := NewService(Config{Repository: repo, Sender: &flakySender{}})
now := time.Now()
mk := func(guid string, st m2mcore.State, at time.Time) {
req := sendFailRequest()
req.Header.GUID = m2m.UUID(guid)
if _, err := repo.Create(ctx, &m2mcore.Deal{ID: guid, GUID: m2m.UUID(guid), State: st, Request: req, CreatedAt: at, UpdatedAt: at}); err != nil {
t.Fatal(err)
}
}
mk("home-done", m2mcore.StateDone, now.Add(-48*time.Hour))
mk("home-rej", m2mcore.StateRejected, now)
mk("home-wait", m2mcore.StateAwaitingDecision, now)
saved := globalIncomingStore
globalIncomingStore = &IncomingStore{items: map[string]*IncomingTransfer{
"in1": {ID: "in1", GUID: "home-in1", ReceivedAt: now, Status: "pending", InvestorLast: "Витвицкий", InvestorFirst: "Владислав"},
}}
defer func() { globalIncomingStore = saved }()
sum, all, recent := homeSummary(ctx, svc, "today")
if want := (homePeriod{Out: homeDir{Total: 2, InProgress: 1, Failed: 1}, In: homeDir{Total: 1, InProgress: 1}}); sum != want {
t.Fatalf("сегодня: %+v", sum)
}
if want := (homePeriod{Out: homeDir{Total: 3, Done: 1, InProgress: 1, Failed: 1}, In: homeDir{Total: 1, InProgress: 1}}); all != want {
t.Fatalf("всего: %+v", all)
}
if len(recent) != 3 {
t.Fatalf("последние за сегодня: %d", len(recent))
}
if s, _, r := homeSummary(ctx, svc, "all"); s != all || len(r) != 4 {
t.Fatalf("за всё время: %+v, строк %d", s, len(r))
}
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
render(rec, a.home, homeData{page: nowPage("Дашборд", "home"), Period: "today", Sum: sum, All: all, Recent: recent})
body := rec.Body.String()
for _, want := range []string{"Переводы за сегодня", `class="on">Сегодня`, "↘ Входящие — к нам", "Витвицкий В.", "3 исходящих · 1 входящих", "весь журнал"} {
if !strings.Contains(body, want) {
t.Errorf("на дашборде нет %q", want)
}
}
w := httptest.NewRecorder()
if p := homePeriodOf(w, httptest.NewRequest("GET", "/admin/?period=all", nil)); p != "all" || !strings.Contains(w.Header().Get("Set-Cookie"), "bj_home_period=all") {
t.Fatalf("выбор периода: %s, %q", p, w.Header().Get("Set-Cookie"))
}
r := httptest.NewRequest("GET", "/admin/", nil)
r.Header.Set("Cookie", "bj_home_period=all")
if p := homePeriodOf(httptest.NewRecorder(), r); p != "all" {
t.Fatalf("период из куки: %s", p)
}
if p := homePeriodOf(httptest.NewRecorder(), httptest.NewRequest("GET", "/admin/", nil)); p != "today" {
t.Fatalf("по умолчанию: %s", p)
}
}
+13 -2
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
@@ -23,6 +24,7 @@ type journalRow struct {
Dir string // "Исходящий" | "Входящий"
DirKind string // "out" | "in"
Party string // контрагент (наименование + код/ИНН)
Investor string // «Фамилия И.» (дашборд)
SecN int
Status string // класс бейджа (для цвета)
StatusText string // человекочитаемый текст статуса (русский)
@@ -58,6 +60,15 @@ func incomingJournalStatus(it *IncomingTransfer) (class, text, outcome string) {
return "confirmed", "Отвечено", ""
}
// investorShort — «Фамилия И.» (rune-safe, пусто для пустого имени).
func investorShort(last, first string) string {
s := strings.TrimSpace(last)
for _, r := range strings.TrimSpace(first) {
return strings.TrimSpace(s + " " + string(r) + ".")
}
return s
}
// incomingStatusVM — статус входящего для шаблонов (класс бейджа + текст + исход).
type incomingStatusVM struct{ Class, Text, Outcome string }
@@ -86,7 +97,7 @@ func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore, f
}
rows = append(rows, journalRow{
Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out",
Party: party, SecN: len(c.Securities),
Party: party, Investor: investorShort(c.Investor.LastName, c.Investor.FirstName), SecN: len(c.Securities),
Status: c.Status, StatusText: russianState(c.Status),
Href: "/admin/claims/" + c.ID,
LiveKey: "out:" + c.ID, Live: liveOutgoing(c),
@@ -107,7 +118,7 @@ func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore, f
cls, txt, outcome := incomingJournalStatus(it)
rows = append(rows, journalRow{
Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in",
Party: party, SecN: len(it.Securities),
Party: party, Investor: investorShort(it.InvestorLast, it.InvestorFirst), SecN: len(it.Securities),
Status: cls, StatusText: txt, Outcome: outcome,
Href: "/admin/incoming/" + it.ID,
LiveKey: "in:" + it.ID, Live: liveIncoming(it),
@@ -38,10 +38,10 @@
<span class="t-sub">{{if devOn}}Симулятор: сценарий робота + кол-во бумаг{{else}}Ручной ввод поручения клиента{{end}}</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a href="/admin/claims" class="tile">
<a href="/admin/journal" class="tile">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h12M8 12h12M8 18h12"/><path d="M3.5 6h.01M3.5 12h.01M3.5 18h.01"/></svg></span>
<span class="t-title">Переводы</span>
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
<span class="t-sub">{{.All.Out.Total}} исходящих · {{.All.In.Total}} входящих</span>
<span class="t-arrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
</a>
<a href="/admin/status" class="tile">
@@ -75,50 +75,61 @@
</div>
{{end}}
{{/* ===== Сводка по переводам ===== */}}
<div class="grid">
<div class="stat accent">
<div class="stat-label">Всего переводов</div>
<div class="stat-value">{{.Counts.Total}}</div>
</div>
<div class="stat ok">
<div class="stat-label">Подтверждено</div>
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
</div>
<div class="stat warn">
<div class="stat-label">В ожидании</div>
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
</div>
<div class="stat err">
<div class="stat-label">Отказы / таймауты</div>
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
</div>
{{/* ===== Сводка по переводам: исходящие и входящие, за сегодня или за всё время ===== */}}
<style>
.period { display:inline-flex; border:1px solid var(--border); border-radius:9px; overflow:hidden; font-size:13px; }
.period a { padding:4px 12px; color:var(--muted); text-decoration:none; }
.period a.on { background:var(--accent-weak); color:var(--accent); font-weight:600; }
a.stat { display:block; color:inherit; text-decoration:none; }
.dirsplit { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; font-size:12.5px; color:var(--muted); }
.dirsplit b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:14px; }
</style>
<div class="section-head" style="align-items:center;gap:10px;flex-wrap:wrap">
<h2>Переводы {{if eq .Period "today"}}за сегодня{{else}}за всё время{{end}}</h2>
<span class="period"><a href="/admin/?period=today"{{if eq .Period "today"}} class="on"{{end}}>Сегодня</a><a href="/admin/?period=all"{{if eq .Period "all"}} class="on"{{end}}>Всего</a></span>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(260px,1fr))">
<a class="stat accent" href="/admin/claims">
<div class="stat-label">↗ Исходящие — от нас в другие депозитарии</div>
<div class="stat-value">{{.Sum.Out.Total}}</div>
{{template "homeDirSplit" .Sum.Out}}
</a>
<a class="stat accent" href="/admin/incoming">
<div class="stat-label">↘ Входящие — к нам</div>
<div class="stat-value">{{.Sum.In.Total}}</div>
{{template "homeDirSplit" .Sum.In}}
</a>
</div>
{{/* ===== Последние переводы ===== */}}
{{/* ===== Последние переводы обоих направлений ===== */}}
<div class="section-head">
<h2>Последние переводы</h2>
<a href="/admin/claims">все →</a>
<a href="/admin/journal">весь журнал →</a>
</div>
<div class="card">
{{if .Recent}}
<div style="overflow-x:auto">
<table>
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<thead><tr><th>Время</th><th>Направление</th><th>Инвестор</th><th>Контрагент</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
<tbody>
{{range .Recent}}
<tr>
<td class="num muted" style="white-space:nowrap">{{.CreatedAt.Format "02.01.2006 15:04:05"}}</td>
<td><code>{{short 8 .ID}}</code></td>
<td>{{.Investor.LastName}} {{initial .Investor.FirstName}}</td>
<td class="num">{{len .Securities}}</td>
<td><span class="badge {{.Status}}">{{ruState .Status}}</span></td>
<td><a href="/admin/claims/{{.ID}}">открыть →</a></td>
<td class="num muted" style="white-space:nowrap">{{msk .Time}}</td>
<td>{{if eq .DirKind "out"}}<span class="badge" style="background:#e7f0fb;color:#1f4e79">↗ Исходящий</span>{{else}}<span class="badge" style="background:#e6f4ea;color:#1a7f37">↘ Входящий</span>{{end}}</td>
<td style="white-space:nowrap">{{.Investor}}</td>
<td>{{.Party}}</td>
<td class="num">{{.SecN}}</td>
<td><span class="badge {{.Status}}">{{.StatusText}}</span>{{if .Outcome}} <span class="badge {{if eq .Outcome "Принято"}}confirmed{{else if eq .Outcome "Отказано"}}rejected{{else}}awaiting_decision{{end}}">{{.Outcome}}</span>{{end}}{{if .Live.Text}}<div class="muted" style="font-size:12px;margin-top:2px">{{.Live.Text}}</div>{{end}}</td>
<td style="white-space:nowrap"><a href="{{.Href}}">открыть →</a></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if eq .Period "today"}}
<p class="muted" style="margin:0">Сегодня переводов не было. <a href="/admin/?period=all">Показать за всё время</a></p>
{{else}}
<p class="muted" style="margin:0">Переводов ещё нет. {{if devOn}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}</p>
<p class="muted" style="margin:0">Переводов ещё нет. {{if devOn}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета и от других депозитариев, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}</p>
{{end}}
</div>
@@ -170,3 +181,5 @@
</div>
{{end}}
{{end}}
{{/* исход переводов одного направления на дашборде (homeDir) */}}
{{define "homeDirSplit"}}<div class="dirsplit"><span><b style="color:var(--ok)">{{.Done}}</b> исполнено</span><span><b style="color:var(--warn)">{{.InProgress}}</b> в работе</span><span><b style="color:var(--err)">{{.Failed}}</b> отказ / истёк срок</span></div>{{end}}