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"` // отпечаток состояния: изменился — карточка перерисовывается } // 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) 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) } 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) // ответы ищем только для этой, живой сделки _, 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: 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 }