Files
Bridge-and-Join-s/internal/lkgateway/livestatus.go
T
zuevav d2024b44ca Ручной ввод: без обязательных галок, удаление черновика, запрет количества больше остатка; отказ НРД по поручению — красным
- ручных отметок перед отправкой нет: автор виден по учётной записи, клиент/счёт/остаток/
  бумага проверены сверкой с DEPO при сохранении; блокировка в QUIK — необязательно,
  по умолчанию не требуется (модуль неторговых поручений сейчас не используется)
- «Удалить черновик» в карточке (POST /admin/manual/{id}/delete), только для черновиков
- количество не больше остатка на торговом разделе: проверка в форме (по выбранной бумаге)
  и на сервере при сохранении (по счёту DEPO) — поручение с превышением не сохраняется
- отчёт MS010 с отказом: orderResponse.Rejected, OutgoingDeal.Rejected; шаг «Операция
  завершена отказом» и бейдж — красные в карточках исходящего и входящего; онлайн-статус
  «поручение 16 завершено отказом НРД»

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 14:39:24 +03:00

166 lines
7.3 KiB
Go

package lkgateway
// livestatus.go — «чего ждёт заявка»: одна строка состояния для журналов и карточек.
//
// Статус сделки (черновик, отправлена, ожидает решение…) говорит, где заявка, но не говорит,
// чего мы ждём: черновика поручения от НРД, подачи, ответа MS010, решения оператора. Здесь это
// сводится в одну фразу с признаком «в работе». Пока заявка в работе, страницы обновляют
// строку по /admin/journal/live каждые несколько секунд; завершённые и отклонённые не
// опрашиваются — там ничего не изменится.
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
)
// LiveStatus — состояние для показа.
type LiveStatus struct {
Text string `json:"text"` // чего ждём / чем закончилось
Kind string `json:"kind"` // wait | warn | ok | bad
Active bool `json:"active"` // в работе: страница продолжает опрашивать
}
// 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
}
// liveOutgoing — состояние исходящего перевода: сделка M2M + депозитарное поручение 16.
func liveOutgoing(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)
}
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"}
}
// liveIncoming — состояние входящего перевода: наше решение, затем поручение 16/1.
func liveIncoming(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
}