From ffd69b069876ba5d0832ae41a532811c2521b061 Mon Sep 17 00:00:00 2001 From: fontvielle Date: Fri, 23 Oct 2026 15:19:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4:=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D1=8B=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B8=D1=85=20=D0=BD=D0=B0=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=C2=AB=D0=A1?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B4=D0=BD=D1=8F=20/=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На 9 при одном входящем переводе (Витвицкий) дашборд показывал «Переводов ещё нет»: сводка и «Последние переводы» считали только исходящие, и только последние 200. - Сводка по направлениям: исходящие и входящие — всего, исполнено, в работе, отказ / истёк срок; исход — по состоянию «чего ждёт заявка» (livestatus.go), то же, что в журнале: принятый, но не исполненный перевод — «в работе». - Период «Сегодня / Всего» (?period=, выбор помнится в куке; по умолчанию — сегодня, с 00:00 МСК). Плитка «Переводы» ведёт в журнал, счётчики за всё время. - «Последние переводы» — общая лента журнала (без анкет участника): направление, инвестор, контрагент, статус и строка состояния. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- internal/lkgateway/admin.go | 97 +++++++++++++------ internal/lkgateway/home_test.go | 76 +++++++++++++++ internal/lkgateway/journal.go | 15 ++- .../lkgateway/web/templates/admin_home.html | 73 ++++++++------ 4 files changed, 202 insertions(+), 59 deletions(-) create mode 100644 internal/lkgateway/home_test.go diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 84f840b..1458924 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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) } diff --git a/internal/lkgateway/home_test.go b/internal/lkgateway/home_test.go new file mode 100644 index 0000000..b3c084b --- /dev/null +++ b/internal/lkgateway/home_test.go @@ -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) + } +} diff --git a/internal/lkgateway/journal.go b/internal/lkgateway/journal.go index df28226..f3cc7b4 100644 --- a/internal/lkgateway/journal.go +++ b/internal/lkgateway/journal.go @@ -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), diff --git a/internal/lkgateway/web/templates/admin_home.html b/internal/lkgateway/web/templates/admin_home.html index c958803..87a36f9 100644 --- a/internal/lkgateway/web/templates/admin_home.html +++ b/internal/lkgateway/web/templates/admin_home.html @@ -38,10 +38,10 @@ {{if devOn}}Симулятор: сценарий робота + кол-во бумаг{{else}}Ручной ввод поручения клиента{{end}} - + Переводы - {{.Counts.Total}} всего · {{.Counts.InProgress}} в работе + {{.All.Out.Total}} исходящих · {{.All.In.Total}} входящих @@ -75,50 +75,61 @@ {{end}} -{{/* ===== Сводка по переводам ===== */}} -
-
-
Всего переводов
-
{{.Counts.Total}}
-
-
-
Подтверждено
-
{{.Counts.Confirmed}}
-
-
-
В ожидании
-
{{.Counts.InProgress}}
-
-
-
Отказы / таймауты
-
{{.Counts.Failed}}
-
+{{/* ===== Сводка по переводам: исходящие и входящие, за сегодня или за всё время ===== */}} + +
+ -{{/* ===== Последние переводы ===== */}} +{{/* ===== Последние переводы обоих направлений ===== */}}

Последние переводы

- все → + весь журнал →
{{if .Recent}} +
- + {{range .Recent}} - - - - - - + + + + + + + {{end}}
ВремяIDИнвесторЦБСтатус
ВремяНаправлениеИнвесторКонтрагентЦБСтатус
{{.CreatedAt.Format "02.01.2006 15:04:05"}}{{short 8 .ID}}{{.Investor.LastName}} {{initial .Investor.FirstName}}{{len .Securities}}{{ruState .Status}}открыть →{{msk .Time}}{{if eq .DirKind "out"}}↗ Исходящий{{else}}↘ Входящий{{end}}{{.Investor}}{{.Party}}{{.SecN}}{{.StatusText}}{{if .Outcome}} {{.Outcome}}{{end}}{{if .Live.Text}}
{{.Live.Text}}
{{end}}
открыть →
+
+ {{else if eq .Period "today"}} +

Сегодня переводов не было. Показать за всё время

{{else}} -

Переводов ещё нет. {{if devOn}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}

+

Переводов ещё нет. {{if devOn}}Нажмите «Новый перевод», чтобы создать первый.{{else}}Они появятся с заявками из личного кабинета и от других депозитариев, а поручение клиента можно ввести вручную — «Новый перевод».{{end}}

{{end}}
@@ -170,3 +181,5 @@
{{end}} {{end}} +{{/* исход переводов одного направления на дашборде (homeDir) */}} +{{define "homeDirSplit"}}
{{.Done}} исполнено{{.InProgress}} в работе{{.Failed}} отказ / истёк срок
{{end}}