Files
Bridge-and-Join-s/internal/lkgateway/livestatus.go
T
fontvielle b442f21818 Журнал/списки: верхний бейдж статуса обновляется живьём, без F5
Поллер строк в работе (/admin/journal/live, tick в layout.html) обновлял
только нижнюю live-строку, а верхний бейдж статуса оставался тем, что
отрисовал сервер при загрузке — после отправки показывал «не отправлено»,
пока страницу не обновишь. LiveStatus получил поля badge/badgeText/outcome
(заполняются в liveOutgoing/liveIncoming из тех же источников, что и строка
журнала), tick синхронизирует верхний бейдж и бейдж исхода. Касается
журнала обмена, «Исходящих» и «Входящих» — у всех общий поллер.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 18:36:40 +03:00

223 lines
10 KiB
Go

package lkgateway
// livestatus.go — «чего ждёт заявка»: одна строка состояния для журналов и карточек.
//
// Статус сделки (черновик, отправлена, ожидает решение…) говорит, где заявка, но не говорит,
// чего мы ждём: черновика поручения от НРД, подачи, ответа MS010, решения оператора. Здесь это
// сводится в одну фразу с признаком «в работе». Пока заявка в работе, страницы обновляют
// строку по /admin/journal/live каждые несколько секунд; завершённые и отклонённые не
// опрашиваются — там ничего не изменится.
import (
"context"
"encoding/json"
"hash/fnv"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// LiveStatus — состояние для показа.
type LiveStatus struct {
Text string `json:"text"` // чего ждём / чем закончилось
Kind string `json:"kind"` // wait | warn | ok | bad
Active bool `json:"active"` // в работе: страница продолжает опрашивать
Sig string `json:"sig,omitempty"` // отпечаток состояния: изменился — карточка перерисовывается
// Верхний бейдж статуса строки журнала/списка — чтобы поллер обновлял и его, а не только
// нижнюю live-строку (иначе бейдж застывает до перезагрузки страницы).
Badge string `json:"badge,omitempty"` // класс бейджа (как .Status в строке)
BadgeText string `json:"badgeText,omitempty"` // русский текст статуса
Outcome string `json:"outcome,omitempty"` // бейдж исхода (входящие): Принято/Отказано/Частично…
}
// liveSig — короткий отпечаток всего, что показывает карточка: сменился — страница обновляется.
func liveSig(parts ...any) string {
raw, _ := json.Marshal(parts)
h := fnv.New64a()
_, _ = h.Write(raw)
return strconv.FormatUint(h.Sum64(), 36)
}
// liveOutgoing — состояние исходящего перевода с отпечатком карточки.
func liveOutgoing(c ClaimView) LiveStatus {
st := liveOutgoingState(c)
guid := string(c.M2MGUID)
var quik []*QuikLink
var depoL *DepoLink
var watch *OrderWatch
if guid != "" {
quik = globalQuikLinks.ForGUID(guid)
if globalDepoLinks != nil {
depoL = globalDepoLinks.Get(guid)
}
if globalOrderWatch != nil {
watch = globalOrderWatch.Get(guid)
}
}
st.Sig = liveSig(st.Text, c.Status, c.UpdatedAt, sendFailGet(guid), quik, depoL, watch)
st.Badge, st.BadgeText = c.Status, russianState(c.Status)
return st
}
// liveIncoming — состояние входящего перевода с отпечатком карточки.
func liveIncoming(it *IncomingTransfer) LiveStatus {
st := liveIncomingState(it)
if it != nil {
var watch *OrderWatch
if globalOrderWatch != nil {
watch = globalOrderWatch.Get(it.GUID)
}
st.Sig = liveSig(st.Text, it, watch)
st.Badge, st.BadgeText, st.Outcome = incomingJournalStatus(it)
}
return st
}
// form16Snapshot — черновики и подачи поручений 16 по всем сделкам одним обходом входящего
// каталога ИШ, с кэшем на несколько секунд: журнал на сотню строк и опрос состояния каждые
// 8 секунд не должны обходить каталог по разу на строку.
var form16Snap struct {
mu sync.Mutex
at time.Time
deal map[string]*OutgoingDeal
}
func form16Snapshot() map[string]*OutgoingDeal {
form16Snap.mu.Lock()
defer form16Snap.mu.Unlock()
if form16Snap.deal != nil && time.Since(form16Snap.at) < 5*time.Second {
return form16Snap.deal
}
m := map[string]*OutgoingDeal{}
if globalOutgoingStore != nil {
for _, d := range scanForm16DraftsOpt(globalOutgoingStore, false) { // ответы — только по сделкам в работе, ниже
m[d.GUID] = d
}
}
form16Snap.deal, form16Snap.at = m, time.Now()
return m
}
// liveOutgoingState — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
func liveOutgoingState(c ClaimView) LiveStatus {
guid := string(c.M2MGUID)
var depo *OutgoingDeal
if guid != "" && c.Status == "confirmed" { // каталог нужен только на этапе депо-исполнения
if d := form16Snapshot()[guid]; d != nil {
cp := *d
if cp.Submitted && len(cp.Responses) == 0 && !cp.Done {
cp.Responses, cp.Done = outgoingResponses(cp.ContrDIDs, orderWatchRefs(guid)...) // ответы ищем только для этой, живой сделки
_, cp.Rejected, _ = orderRespVerdict(cp.Responses)
}
depo = &cp
}
}
var watch *OrderWatch
if guid != "" && globalOrderWatch != nil {
watch = globalOrderWatch.Get(guid)
}
if f := sendFailGet(guid); f != nil && guid != "" {
return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; отправится повторно сама, когда НРД ответит", Kind: "err", Active: true}
}
switch c.Status {
case "draft", "validated":
return LiveStatus{Text: "ожидает отправки в НРД", Kind: "wait", Active: true}
case "submitted_to_nsd":
return LiveStatus{Text: "отправлена в НРД, ожидает подтверждения приёма", Kind: "wait", Active: true}
case "awaiting_decision":
return LiveStatus{Text: "ожидает решения принимающего депозитария", Kind: "wait", Active: true}
case "manual_approval":
return LiveStatus{Text: "ожидает решения оператора", Kind: "warn", Active: true}
case "awaiting_sub16":
return LiveStatus{Text: "принята, ожидает депозитарного исполнения", Kind: "wait", Active: true}
case "rejected":
return LiveStatus{Text: "отклонена", Kind: "bad"}
case "timed_out":
return LiveStatus{Text: "истёк срок ответа контрагента", Kind: "bad"}
case "done":
return LiveStatus{Text: "исполнена", Kind: "ok"}
case "confirmed":
switch {
case depo == nil:
return LiveStatus{Text: "принята контрагентом, ожидает черновика поручения 16 от НРД", Kind: "wait", Active: true}
case !depo.Submitted:
return LiveStatus{Text: "черновик поручения 16 получен, ожидает подачи в НРД", Kind: "warn", Active: true}
case depo.Done && depo.Rejected:
return LiveStatus{Text: "поручение 16 завершено отказом НРД (MS010)", Kind: "bad"}
case depo.Done:
if watch != nil && !watch.MS010At.IsZero() {
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен " + watch.MS010At.In(mskZone).Format("02.01.2006 15:04"), Kind: "ok"}
}
return LiveStatus{Text: "поручение 16 исполнено: MS010 получен", Kind: "ok"}
case watch != nil:
k := watch.Kind()
if k == "" {
k = "wait"
}
return LiveStatus{Text: "поручение 16 подано: " + watch.StatusRu(), Kind: k, Active: k == "wait" || k == "warn"}
default:
return LiveStatus{Text: "поручение 16 подано, ожидает ответа НРД (MS010)", Kind: "wait", Active: true}
}
}
return LiveStatus{Text: russianState(c.Status), Kind: "wait"}
}
// liveIncomingState — состояние входящего перевода: наше решение, затем поручение 16/1.
func liveIncomingState(it *IncomingTransfer) LiveStatus {
if it == nil {
return LiveStatus{}
}
if it.Status != "answered" {
return LiveStatus{Text: "ожидает нашего решения", Kind: "warn", Active: true}
}
_, _, outcome := incomingJournalStatus(it)
if outcome == "Отказано" {
return LiveStatus{Text: "отказано", Kind: "bad"}
}
if globalOrderWatch != nil {
if w := globalOrderWatch.Get(it.GUID); w != nil {
k := w.Kind()
return LiveStatus{Text: "поручение 16/1 подано: " + w.StatusRu(), Kind: k, Active: k == "wait" || k == "warn"}
}
}
if strings.HasPrefix(outcome, "Частично") {
return LiveStatus{Text: strings.ToLower(outcome) + "; ожидает поручения 16/1", Kind: "wait", Active: true}
}
return LiveStatus{Text: "принято, ожидает подачи поручения 16/1", Kind: "wait", Active: true}
}
// registerLiveStatus — GET /admin/journal/live?out=id,id&in=id,id → состояния для строк в работе.
func registerLiveStatus(mux *http.ServeMux, svc *Service, store *IncomingStore) {
mux.HandleFunc("/admin/journal/live", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
out := map[string]LiveStatus{}
for _, id := range splitIDs(r.URL.Query().Get("out")) {
if c, err := svc.GetClaim(ctx, id); err == nil {
out[id] = liveOutgoing(c)
}
}
in := map[string]LiveStatus{}
for _, id := range splitIDs(r.URL.Query().Get("in")) {
if it, ok := store.Get(id); ok {
in[id] = liveIncoming(it)
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"out": out, "in": in})
})
}
func splitIDs(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" && len(p) <= 64 && len(out) < 200 {
out = append(out, p)
}
}
return out
}