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: | in: — для онлайн-обновления строки 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}) }) }