package lkgateway // Тест «боевой ЛК → тестовый контур НРД» (29.09.2026). Заявка реального клиента из ЛК ЕСИА Финанс // проходит у нас всё по-настоящему: проверка шлюза и поручение в DEPO, блокировка и списание в QUIK // МНП (боевая запись — только для таких заявок, остальные остаются в имитации), статусы в ЛК. В НРД // (TEST3) запрос уходит с подменой блока инвестора на тест-клиента, зарегистрированного в тестовом // контуре (Бубликов): GUID, референсы, бумаги и счета — настоящие, поэтому все ответы НРД по GUID // применяются к заявке клиента. «Исполнена» в ЛК — только по отчёту НРД об исполнении (MS010). import ( "context" "encoding/json" "fmt" "log" "net/http" "os" "strings" "sync" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" ) // RealNSDTestSettings — кто идёт в НРД с подменой и под чьими данными. type RealNSDTestSettings struct { Enabled bool `json:"enabled"` // Docs — паспорта клиентов (серия номер), чьи заявки идут в НРД с подменой, через «;». Docs string `json:"docs,omitempty"` // As — тест-инвестор, зарегистрированный в тестовом контуре НРД. Пусто — Бубликов (К1). AsLast string `json:"as_last,omitempty"` AsFirst string `json:"as_first,omitempty"` AsMiddle string `json:"as_middle,omitempty"` AsSeries string `json:"as_series,omitempty"` AsNumber string `json:"as_number,omitempty"` } func (s RealNSDTestSettings) as() (last, first, middle, ser, num string) { if s.AsLast == "" || s.AsNumber == "" { k := m2mTestClients["К1"] return k.Last, k.First, k.Middle, k.DocSeries, k.DocNumber } return s.AsLast, s.AsFirst, s.AsMiddle, s.AsSeries, s.AsNumber } func normDoc(ser, num string) string { return strings.ReplaceAll(strings.TrimSpace(ser)+strings.TrimSpace(num), " ", "") } // realTestMatch — заявка этого клиента идёт в НРД с подменой. func realTestMatch(rc *RuntimeConfig, ser, num string) bool { if rc == nil { return false } t := rc.Snapshot().LK.RealNSDTest if !t.Enabled || normDoc(ser, num) == "" { return false } for _, d := range strings.Split(t.Docs, ";") { f := strings.Fields(d) if len(f) >= 1 && normDoc(strings.Join(f[:len(f)-1], ""), f[len(f)-1]) == normDoc(ser, num) { return true } } return false } // RealTestRec — заявка, ушедшая в НРД с подменой. type RealTestRec struct { GUID string `json:"guid"` ClaimID int `json:"claim_id,omitempty"` // заявка ЛК Client string `json:"client"` // ФИО и паспорт клиента As string `json:"as"` // под чьими данными ушло в НРД At time.Time `json:"at"` LKDone bool `json:"lk_done,omitempty"` // итог передан в ЛК } var realTestFile = "/var/lib/bj/.bj/real-nsd-test.json" var realTests = struct { sync.Mutex loaded bool m map[string]*RealTestRec }{} func realTestLoad() { if realTests.loaded { return } realTests.loaded, realTests.m = true, map[string]*RealTestRec{} if raw, err := os.ReadFile(realTestFile); err == nil { _ = json.Unmarshal(raw, &realTests.m) } } func realTestSave() { if raw, err := json.MarshalIndent(realTests.m, "", " "); err == nil { _ = os.WriteFile(realTestFile, raw, 0o600) } } func realTestGet(guid string) *RealTestRec { if guid == "" { return nil } realTests.Lock() defer realTests.Unlock() realTestLoad() if r, ok := realTests.m[strings.ToLower(guid)]; ok { cp := *r return &cp } return nil } func realTestPut(r RealTestRec) { realTests.Lock() defer realTests.Unlock() realTestLoad() r.GUID = strings.ToLower(r.GUID) realTests.m[r.GUID] = &r realTestSave() } // realTestAny — есть тестовые заявки, по которым ещё ждём итог (опросчик QUIK должен работать). func realTestAny() bool { realTests.Lock() defer realTests.Unlock() realTestLoad() for _, r := range realTests.m { if !r.LKDone { return true } } return false } func realTestMarkLKDone(guid string) bool { realTests.Lock() defer realTests.Unlock() realTestLoad() r, ok := realTests.m[strings.ToLower(guid)] if !ok || r.LKDone { return false } r.LKDone = true realTestSave() return true } // substituteSender — отправитель в НРД с подменой блока инвестора; сделка у нас хранит исходный запрос. type substituteSender struct { real m2mcore.NSDSender inv m2m.InvestorInformation } func (s substituteSender) Send(ctx context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) { cp := *req cp.Data.InvestorInformation = s.inv return s.real.Send(ctx, &cp) } func (s substituteSender) SendDecision(ctx context.Context, d *m2m.M2MTransferDecision) error { return s.real.SendDecision(ctx, d) } // realTestInvestor — блок инвестора тест-клиента в формате запроса НРД; тип документа — как у клиента. func realTestInvestor(rc *RuntimeConfig, docType m2m.IdentityDocumentCode) (m2m.InvestorInformation, string) { last, first, middle, ser, num := rc.Snapshot().LK.RealNSDTest.as() inv := m2m.InvestorInformation{LastName: last, FirstName: first, MiddleName: middle} inv.IdentityDocument.DocumentType = docType if ser != "" { sv := m2m.IdentityDocSerial(ser) inv.IdentityDocument.DocumentSeries = &sv } inv.IdentityDocument.DocumentNumber = m2m.IdentityDocSerial(num) return inv, strings.TrimSpace(fmt.Sprintf("%s %s %s, %s %s", last, first, middle, ser, num)) } // realTestSenderFor — отправитель для заявки: с подменой, если заявка из теста; иначе def. func realTestSenderFor(rc *RuntimeConfig, guid string, req *m2m.M2MTransferRequest, def m2mcore.NSDSender) m2mcore.NSDSender { if realTestGet(guid) == nil || req == nil || rc == nil { return def } inv, _ := realTestInvestor(rc, req.Data.InvestorInformation.IdentityDocument.DocumentType) return substituteSender{real: def, inv: inv} } // quikSettingsFor — настройки МНП для перевода: у тестовых заявок «боевой ЛК → TEST3» запись в МНП // настоящая (по решению 29.09.2026), у остальных — как в настройках. func quikSettingsFor(rc *RuntimeConfig, guid string) QuikMNPSettings { s := rc.Snapshot().Quik if realTestGet(guid) != nil { s.Emulate, s.WritesEnabled = false, true } return s } // realTestOnExecuted — НРД исполнил (или отверг) наше поручение 16: итог — в ЛК. func realTestOnExecuted(ctx context.Context, rc *RuntimeConfig, guid string, rejected bool, partial string) { if realTestGet(guid) == nil || !realTestMarkLKDone(guid) { return } status, reason := "done", "Перевод исполнен: бумаги списаны" switch { case rejected && partial == "": status, reason = "rejected", "НРД не исполнил поручение на списание" case partial != "": reason = "Перевод исполнен частично: " + partial } globalLKFlows.stepIfExists(guid, pick3(status == "done", "response", "reject"), "Итог НРД (MS010) → статус в ЛК", reason) OnLKOutcome(ctx, rc, guid, status, reason) log.Printf("real-test: %s — итог в ЛК: %s (%s)", shortGUID(guid), status, reason) } // registerRealNSDTest — POST /admin/setup/lk-real-nsd: включить/выключить тест и список клиентов // (в «Настройка → ЛК ЕСИА Финанс», доступно без режима разработки — право «настройка»). func registerRealNSDTest(mux *http.ServeMux, rc *RuntimeConfig) { mux.HandleFunc("/admin/setup/lk-real-nsd", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method", http.StatusMethodNotAllowed) return } lk := rc.Snapshot().LK lk.RealNSDTest.Enabled = r.FormValue("enabled") != "" lk.RealNSDTest.Docs = strings.TrimSpace(r.FormValue("docs")) if lk.RealNSDTest.Enabled { lk.EmulateNSD = false // режимы эмуляции НРД взаимоисключающие (подмена ≠ полная имитация) } if err := rc.UpdateLK(lk); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } log.Printf("real-test: %v, клиенты: %s", lk.RealNSDTest.Enabled, lk.RealNSDTest.Docs) back := "/admin/setup" switch b := r.FormValue("back"); b { case "/admin/m2m-testing", "/admin/emulators", "/admin/setup": back = b default: if ref := r.Referer(); strings.Contains(ref, "/admin/m2m-testing") { back = "/admin/m2m-testing" } else if strings.Contains(ref, "/admin/emulators") { back = "/admin/emulators" } } http.Redirect(w, r, back+"#lk-real-nsd", http.StatusSeeOther) }) } // realNSDAsText — тест-инвестор для подписи в настройках. func realNSDAsText() string { if globalRC == nil { return "" } l, f, m, sr, n := globalRC.Snapshot().LK.RealNSDTest.as() return strings.TrimSpace(l + " " + f + " " + m + ", " + sr + " " + n) }