ffd69b0698
На 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
198 lines
7.9 KiB
Go
198 lines
7.9 KiB
Go
package lkgateway
|
|
|
|
// journal.go — объединённый журнал обмена: входящие + исходящие переводы в одной
|
|
// хронологической ленте (плюс отдельные виды — «Исходящие» и «Входящие» на своих
|
|
// страницах). Для быстрого обзора всего потока с контрагентами.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
|
)
|
|
|
|
// journalRow — одна строка объединённого журнала.
|
|
type journalRow struct {
|
|
Time time.Time
|
|
Dir string // "Исходящий" | "Входящий"
|
|
DirKind string // "out" | "in"
|
|
Party string // контрагент (наименование + код/ИНН)
|
|
Investor string // «Фамилия И.» (дашборд)
|
|
SecN int
|
|
Status string // класс бейджа (для цвета)
|
|
StatusText string // человекочитаемый текст статуса (русский)
|
|
Outcome string // для входящих: «Принято» / «Отказано» / «Частично …»
|
|
Href string
|
|
LiveKey string // out:<id> | in:<id> — для онлайн-обновления строки
|
|
Live LiveStatus // чего ждёт заявка
|
|
}
|
|
|
|
// incomingJournalStatus — статус входящего для журнала: класс бейджа (цвет),
|
|
// текст и исход по бумагам (принято/отказано/частично).
|
|
func incomingJournalStatus(it *IncomingTransfer) (class, text, outcome string) {
|
|
if it.Status != "answered" {
|
|
return "pending", "Ждёт решения", ""
|
|
}
|
|
conf, rej := 0, 0
|
|
for _, s := range it.Securities {
|
|
switch s.Decision {
|
|
case "confirmed":
|
|
conf++
|
|
case "rejected":
|
|
rej++
|
|
}
|
|
}
|
|
switch {
|
|
case rej == 0 && conf > 0:
|
|
return "confirmed", "Отвечено", "Принято"
|
|
case conf == 0 && rej > 0:
|
|
return "rejected", "Отвечено", "Отказано"
|
|
case conf > 0 && rej > 0:
|
|
return "awaiting_decision", "Отвечено", fmt.Sprintf("Частично: принято %d, отказано %d", conf, rej)
|
|
}
|
|
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 }
|
|
|
|
// incomingStatusOf — статус входящего для страницы «Входящие» (template-func).
|
|
func incomingStatusOf(it *IncomingTransfer) incomingStatusVM {
|
|
c, t, o := incomingJournalStatus(it)
|
|
return incomingStatusVM{Class: c, Text: t, Outcome: o}
|
|
}
|
|
|
|
// buildJournalRows — объединённая лента (исходящие + входящие), отсортированная от новых к старым.
|
|
func buildJournalRows(ctx context.Context, svc *Service, store *IncomingStore, from time.Time) []journalRow {
|
|
var rows []journalRow
|
|
|
|
// Исходящие (наши заявки в НРД); from — с какого времени (нулевое — все).
|
|
f := m2mcore.Filter{Limit: 100000}
|
|
if !from.IsZero() {
|
|
f.CreatedFrom = &from
|
|
}
|
|
if page, err := svc.ListClaims(ctx, f); err == nil {
|
|
for _, c := range page.Items {
|
|
party := m2mPartyName(c.ReceivingDepositoryINN)
|
|
if party != "" {
|
|
party += " · ИНН " + c.ReceivingDepositoryINN
|
|
} else {
|
|
party = "ИНН " + c.ReceivingDepositoryINN
|
|
}
|
|
rows = append(rows, journalRow{
|
|
Time: c.CreatedAt, Dir: "Исходящий", DirKind: "out",
|
|
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),
|
|
})
|
|
}
|
|
}
|
|
// Входящие (переводы к нам).
|
|
for _, it := range store.List() {
|
|
party := m2mPartyName(it.SenderCode)
|
|
if party == "" {
|
|
party = m2mPartyName(it.SenderINN)
|
|
}
|
|
if party != "" {
|
|
party += " · " + it.SenderCode
|
|
} else {
|
|
party = it.SenderCode
|
|
}
|
|
cls, txt, outcome := incomingJournalStatus(it)
|
|
rows = append(rows, journalRow{
|
|
Time: it.ReceivedAt, Dir: "Входящий", DirKind: "in",
|
|
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),
|
|
})
|
|
}
|
|
// Анкеты участника M2M — исходящие пакеты в НРД с ответом на них.
|
|
for _, rg := range loadParticipantRegs() {
|
|
rows = append(rows, journalRow{
|
|
Time: rg.SentAt, Dir: "Исходящий", DirKind: "out",
|
|
Party: "НРД · анкета участника M2M",
|
|
Status: rg.StatusClass(), StatusText: rg.StatusText(),
|
|
Href: "/admin/setup/participant-form",
|
|
LiveKey: "pf:" + rg.PkgID,
|
|
})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
|
|
return rows
|
|
}
|
|
|
|
// registerJournal вешает /admin/journal — объединённая лента вход+исход.
|
|
func registerJournal(mux *http.ServeMux, a *admin, svc *Service, store *IncomingStore) {
|
|
registerLiveStatus(mux, svc, store) // «чего ждёт заявка» для строк в работе
|
|
mux.HandleFunc("/admin/journal", func(w http.ResponseWriter, r *http.Request) {
|
|
q := parseListQ(r, []string{"time", "dir", "party", "status"})
|
|
var items []listItem
|
|
for _, row := range buildJournalRows(r.Context(), svc, store, q.From) {
|
|
items = append(items, listItem{T: row.Time, V: row, Keys: map[string]string{"dir": row.Dir, "party": row.Party, "status": row.StatusText}})
|
|
}
|
|
rows, total, more := listApply(items, q)
|
|
if r.URL.Query().Get("part") == "rows" {
|
|
listRowsPart(w, a.journal, "journalrow", rows, q, more)
|
|
return
|
|
}
|
|
data := struct {
|
|
page
|
|
Rows []any
|
|
List listView
|
|
}{nowPage("Журнал обмена", "journal"), listVals(rows),
|
|
newListView("/admin/journal", "journal-body", q, total, more, "time", "по времени", "dir", "по направлению", "party", "по контрагенту", "status", "по статусу")}
|
|
render(w, a.journal, data)
|
|
})
|
|
|
|
// /admin/journal/rows — только новые строки (появившиеся после since, unix-наносекунды).
|
|
// Страница подставляет их сверху без перезагрузки. count — общее число строк (для счётчика).
|
|
mux.HandleFunc("/admin/journal/rows", func(w http.ResponseWriter, r *http.Request) {
|
|
var since int64
|
|
if s := r.URL.Query().Get("since"); s != "" {
|
|
since, _ = strconv.ParseInt(s, 10, 64)
|
|
}
|
|
var from time.Time
|
|
if since > 0 {
|
|
from = time.Unix(0, since)
|
|
}
|
|
rows := buildJournalRows(r.Context(), svc, store, from)
|
|
type rowJSON struct {
|
|
Key string `json:"key"`
|
|
T int64 `json:"t"`
|
|
HTML string `json:"html"`
|
|
}
|
|
out := make([]rowJSON, 0, 8)
|
|
for _, row := range rows {
|
|
t := row.Time.UnixNano()
|
|
if since > 0 && t <= since {
|
|
continue // уже есть на странице
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := a.journal.ExecuteTemplate(&buf, "journalrow", row); err != nil {
|
|
continue
|
|
}
|
|
out = append(out, rowJSON{Key: row.LiveKey, T: t, HTML: buf.String()})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"count": len(rows), "rows": out})
|
|
})
|
|
}
|