package lkgateway // «Проверить всю цепочку» — сквозная проверка без настоящего перевода (05.10.2026). Каждое звено — только // если оно есть в лицензии и настроено: // - учёт DEPO: проверочное событие BJ_PING обработано процедурами обмена; // - QUIK МНП: объекты, инструменты, справочник получателей (только чтение); // - QUIK МНП, по желанию: пробная блокировка 1 шт. бумаги клиента («Вывод ЦБ (M2M)») и сразу снятие; // - НРД: запрос справочника участников уходит через шлюз — шлюз подписывает его ключом организации (ИШ — // единственный владелец ключа для НРД), НРД принимает и отвечает, ответ приходит во входящие. // Звенья идут по очереди; упавшее не останавливает остальные — видно всю картину. import ( "context" "encoding/json" "net/http" "regexp" "sort" "strconv" "strings" "sync" "time" ) // ChainStep — звено проверки. type ChainStep struct { Key string `json:"key"` Title string `json:"title"` State string `json:"state"` // wait | run | ok | warn | err Detail string `json:"detail,omitempty"` } var chainTest struct { sync.Mutex Running bool Started time.Time Finished time.Time Steps []ChainStep } func chainSet(i int, state, detail string) { chainTest.Lock() defer chainTest.Unlock() chainTest.Steps[i].State, chainTest.Steps[i].Detail = state, detail } type chainQuikBlock struct { Client, ISIN, INN string } var ( quikClientRe = regexp.MustCompile(`^[A-Za-z0-9_+-]{1,20}$`) isinRe = regexp.MustCompile(`^[A-Z]{2}[A-Z0-9]{9}[0-9]$`) innRe = regexp.MustCompile(`^\d{10}$`) ) func chainRun(rc *RuntimeConfig, nsdReal bool, blk *chainQuikBlock) { s := rc.Snapshot() mnp := moduleAllowed(rc, "quik-mnp") var steps []ChainStep if mnp && strings.TrimSpace(s.Fancy.Server) != "" { steps = append(steps, ChainStep{Key: "depo", Title: "Учёт DEPO: проверочное событие обработано"}) } if mnp && strings.TrimSpace(s.Quik.Server) != "" { steps = append(steps, ChainStep{Key: "quik", Title: "QUIK МНП: модуль неторговых поручений готов"}) if blk != nil { steps = append(steps, ChainStep{Key: "quik-block", Title: "QUIK МНП: пробная блокировка 1 бумаги и снятие"}) } } steps = append(steps, ChainStep{Key: "nsd", Title: "НРД: запрос через шлюз подписан ключом, доставлен, ответ получен"}) for i := range steps { steps[i].State = "wait" } chainTest.Lock() chainTest.Steps, chainTest.Started, chainTest.Finished = steps, time.Now(), time.Time{} chainTest.Unlock() defer func() { chainTest.Lock() chainTest.Running, chainTest.Finished = false, time.Now() chainTest.Unlock() }() for i, st := range steps { chainSet(i, "run", "") state, detail := chainStepRun(rc, s, st.Key, nsdReal, blk) chainSet(i, state, detail) } } func chainStepRun(rc *RuntimeConfig, s Settings, key string, nsdReal bool, blk *chainQuikBlock) (string, string) { switch key { case "depo": ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() db, err := depoEventsDB(rc) if err != nil { return "err", "подключение к DEPO: " + err.Error() } defer db.Close() if msg := depoObjectsMissing(ctx, db, s.Fancy); msg != "" { return "err", msg } if ok, msg := depoJobPing(ctx, db, 60*time.Second); !ok { return "err", msg } else { return "ok", msg } case "quik": ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() cs := quikMNPCheck(ctx, s.Quik) var bad []string for _, c := range cs { if c.State == "err" { bad = append(bad, c.Title+": "+c.Detail) } } if len(bad) > 0 { return "err", strings.Join(bad, "; ") } if mnpWorst(cs) == "warn" { return "warn", "модуль на месте; есть замечания — подробно в разделе «QUIK МНП»" } return "ok", "подключение, объекты M2M, инструменты и справочник получателей на месте" case "quik-block": return chainQuikBlockRun(s, blk) case "nsd": if !nsdReal { return "err", "шлюз НРД не настроен — bj работает в режиме имитации (раздел «Шлюз НРД»)" } start := time.Now() nsdProbeNow() deadline := start.Add(4 * time.Minute) for time.Now().Before(deadline) { time.Sleep(2 * time.Second) nsdH.Lock() sent, reply, perr, deliv, delErr, delErrAt := nsdH.ProbeSentAt, nsdH.ProbeReplyAt, nsdH.ProbeErr, nsdH.LastDelivered, nsdH.LastDelErr, nsdH.LastDelErrAt nsdH.Unlock() if sent.After(start) && perr != "" { return "err", "запрос не отправлен в шлюз: " + perr } if delErrAt.After(start) && delErr != "" { return "err", "шлюз не доставил пакет в НРД: " + delErr } if reply.After(start) { d := "НРД ответил за " + humanDur(reply.Sub(sent)) + " — ключ организации, шлюз, доставка и входящие работают" if deliv.After(start) { d = "пакет доставлен в НРД, " + d } return "ok", d } } return "err", "НРД не ответил за 4 минуты — проверьте связь шлюза с контуром НРД (раздел «Шлюз НРД») и доступ к контуру" } return "err", "неизвестное звено" } // chainQuikBlockRun — «Вывод ЦБ (M2M)» 1 шт. и немедленное снятие. Запись в МНП разрешена только для // этой проверки — пользователь явно включил её и подтвердил. func chainQuikBlockRun(s Settings, blk *chainQuikBlock) (string, string) { q := s.Quik q.Emulate, q.WritesEnabled = false, true ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() ext := "BJ-CHAIN-" + time.Now().Format("060102150405") if err := quikEnsureRecipient(ctx, q, blk.INN, ""); err != nil { return "err", "депозитарий-получатель в справочнике МНП: " + quikErrorDetail(err) } aid, err := quikExecNewInstr(ctx, q, true, ext, blk.Client, blk.ISIN, 1, blk.INN, true, "bj: проверка цепочки — пробная блокировка") if err != nil { return "err", "регистрация поручения: " + quikErrorDetail(err) } r := quikWaitAction(ctx, q, aid, 90*time.Second) if r.QueryStatus != 2 || r.InstrNo == "" || r.InstrNo == "0" { return "err", "МНП не принял поручение: " + pickStr(r.ReplyText, quikStatusResultName(r.QueryStatus)) } no, _ := strconv.ParseFloat(r.InstrNo, 64) aid2, err := quikExecDeny(ctx, q, no, "bj: проверка цепочки — снятие пробной блокировки") if err != nil { return "err", "поручение №" + r.InstrNo + " зарегистрировано, но не снято: " + quikErrorDetail(err) + " — снимите его в QUIK вручную" } r2 := quikWaitAction(ctx, q, aid2, 90*time.Second) if r2.QueryStatus != 2 { return "err", "поручение №" + r.InstrNo + " зарегистрировано, снятие не подтверждено: " + pickStr(r2.ReplyText, quikStatusResultName(r2.QueryStatus)) + " — проверьте в QUIK" } return "ok", "поручение №" + r.InstrNo + " («Вывод ЦБ (M2M)», 1 шт. " + blk.ISIN + ") зарегистрировано — бумага заблокирована — и снято" } func registerChainTest(mux *http.ServeMux, rc *RuntimeConfig, nsdReal bool) { mux.HandleFunc("/admin/setup/chain-test", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } var blk *chainQuikBlock if r.FormValue("quik_block") != "" { if !moduleAllowed(rc, "quik-mnp") { jsonOut(w, map[string]any{"ok": false, "error": "модуль QUIK не входит в лицензию"}) return } b := chainQuikBlock{Client: strings.TrimSpace(r.FormValue("quik_client")), ISIN: strings.ToUpper(strings.TrimSpace(r.FormValue("quik_isin"))), INN: strings.TrimSpace(r.FormValue("quik_inn"))} switch { case !quikClientRe.MatchString(b.Client): jsonOut(w, map[string]any{"ok": false, "error": "код клиента QUIK — буквы и цифры, например BR859"}) return case !isinRe.MatchString(b.ISIN): jsonOut(w, map[string]any{"ok": false, "error": "ISIN — 12 знаков, например RU0009029540"}) return case !innRe.MatchString(b.INN): jsonOut(w, map[string]any{"ok": false, "error": "ИНН депозитария-получателя — 10 цифр"}) return } blk = &b } chainTest.Lock() if chainTest.Running { chainTest.Unlock() jsonOut(w, map[string]any{"ok": false, "error": "проверка уже идёт"}) return } chainTest.Running = true chainTest.Unlock() go chainRun(rc, nsdReal, blk) jsonOut(w, map[string]any{"ok": true}) }) mux.HandleFunc("/admin/setup/chain-test/status", func(w http.ResponseWriter, r *http.Request) { chainTest.Lock() defer chainTest.Unlock() out := map[string]any{"running": chainTest.Running, "steps": chainTest.Steps} if !chainTest.Started.IsZero() { out["started"] = chainTest.Started.Format("02.01.2006 15:04:05") } if !chainTest.Finished.IsZero() { out["finished"] = chainTest.Finished.Format("15:04:05") } jsonOut(w, out) }) } // handbookDepos — депозитарии справочника участников M2M (кроме своей организации) — для выбора получателя // пробной блокировки: они же есть в справочнике получателей МНП (bj синхронизирует его со справочником НРД). func handbookDepos() []HandbookEntry { raw, _ := readHandbook() var hb struct { Participants []HandbookEntry `json:"participants"` } _ = json.Unmarshal(raw, &hb) own := "" if globalRC != nil { own = orgINN(globalRC.Snapshot()) } var out []HandbookEntry for _, p := range hb.Participants { if p.Kind == "depo" && p.INN != "" && p.INN != own { out = append(out, p) } } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out }