From 91d8584a9c7f85d4e6bad9349bd6dd15f1211d7b Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 29 Sep 2026 13:10:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=C2=AB=D0=B1=D0=BE?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=9B=D0=9A=20=E2=86=92=20TEST3?= =?UTF-8?q?=C2=BB:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=95=D0=A1=D0=98=D0=90=20?= =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=20=E2=80=94=20=D1=83=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=20=D0=B2=D1=81=D1=91=20=D0=BF=D0=BE-=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B5=D0=BC=D1=83=20(DEPO,?= =?UTF-8?q?=20QUIK=20=D0=9C=D0=9D=D0=9F=20=D0=B1=D0=BE=D0=B5=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20=D0=BD=D0=B8?= =?UTF-8?q?=D1=85),=20=D0=B2=20=D0=9D=D0=A0=D0=94=20=D1=81=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BC=D0=B5=D0=BD=D0=BE=D0=B9=20=D0=B8=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B0=20=D0=BD=D0=B0=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82-=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0;=20?= =?UTF-8?q?=C2=AB=D0=98=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=C2=BB=20=D0=B2=20=D0=9B=D0=9A=20=D0=BF=D0=BE=20MS010;=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=82=D0=BE=D1=80=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=82=D0=BE=D0=B6=D0=B5=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D0=BE=D0=B9;=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=D0=B8=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=20=D0=9B=D0=9A=20=D0=B2=20=D0=BF=D1=83=D1=82=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8;=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=A2=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20M2M=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- internal/lkgateway/admin.go | 7 +- internal/lkgateway/br859_live_test.go | 43 ++++ internal/lkgateway/claimtimeline.go | 8 +- internal/lkgateway/lkemulate.go | 41 ++- internal/lkgateway/m2mtest.go | 6 + internal/lkgateway/orderwatch.go | 9 +- internal/lkgateway/quikcycle.go | 6 +- internal/lkgateway/quikhooks.go | 20 +- internal/lkgateway/realtest.go | 233 ++++++++++++++++++ internal/lkgateway/realtest_test.go | 71 ++++++ internal/lkgateway/runtimeconfig.go | 4 + internal/lkgateway/sber100_live_test.go | 39 +++ internal/lkgateway/sendfail.go | 4 +- .../lkgateway/web/templates/admin_claim.html | 4 + .../web/templates/admin_m2mtest.html | 11 + 15 files changed, 485 insertions(+), 21 deletions(-) create mode 100644 internal/lkgateway/br859_live_test.go create mode 100644 internal/lkgateway/realtest.go create mode 100644 internal/lkgateway/realtest_test.go create mode 100644 internal/lkgateway/sber100_live_test.go diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 116b4cf..b18bc01 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -52,6 +52,7 @@ var templateFuncs = template.FuncMap{ "curUser": authTemplateFuncs["curUser"], "can": authTemplateFuncs["can"], "sendFailText": sendFailText, + "realTest": realTestGet, "nsdSegTip": nsdSegTip, "devOn": authTemplateFuncs["devOn"], "devLicensed": authTemplateFuncs["devLicensed"], @@ -676,9 +677,9 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent { tl = append(tl, ev) } - // Заявка ЛК, прошедшая шлюз bj (lkflow.go): путь целиком из сохранённых шагов — - // проверка шлюза, Fancy, имитатор НРД, статус в ЛК. - if v.M2MGUID != "" { + // Заявка ЛК, прошедшая шлюз bj (lkflow.go), без сделки в НРД (отказ шлюза, имитатор): путь целиком + // из сохранённых шагов. Если заявка ушла в настоящий НРД — шаги ЛК входят в общий путь ниже. + if v.M2MGUID != "" && realTestGet(string(v.M2MGUID)) == nil { if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 { var prev time.Time for _, st := range f.Steps { diff --git a/internal/lkgateway/br859_live_test.go b/internal/lkgateway/br859_live_test.go new file mode 100644 index 0000000..d773626 --- /dev/null +++ b/internal/lkgateway/br859_live_test.go @@ -0,0 +1,43 @@ +//go:build live + +package lkgateway + +import ( + "context" + "fmt" + "testing" +) + +// Только чтение: кто в DEPO стоит за договором (portfolio) и его бумаги. TL_CONTRACT=БР-859. +func TestContractLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + fs := rc.Snapshot().Fancy + ctx := context.Background() + cs, err := depoListContracts(ctx, fs, "859", 20) + fmt.Println("договоры:", cs, err) + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + rows, err := db.QueryContext(ctx, `SELECT p.PORT_NUM, c.NUMBER, ISNULL(f.NAME,''), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''), c.STATE + FROM OD_CLIENTS_PORTS p JOIN OD_CLIENTS c ON c.ID=p.CLIENT JOIN OD_FACES f ON f.ID=c.FACE + WHERE p.PORT_NUM LIKE @p1`, "%859%") + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var port, num, name, ser, dn string + var st int + _ = rows.Scan(&port, &num, &name, &ser, &dn, &st) + fmt.Printf("портфель %s счёт %s ФИО %q ДУЛ %s %s state %d\n", port, num, name, ser, dn, st) + } + pos, err := depoClientPositions(ctx, fs, "БР-859") + fmt.Println("позиции:", err) + for _, p := range pos { + fmt.Printf(" %+v\n", p) + } +} diff --git a/internal/lkgateway/claimtimeline.go b/internal/lkgateway/claimtimeline.go index d2d03a7..43ea8d1 100644 --- a/internal/lkgateway/claimtimeline.go +++ b/internal/lkgateway/claimtimeline.go @@ -130,7 +130,13 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent { origin = fmt.Sprintf("Заявка из личного кабинета ЕСИА Финанс (заявка ЛК #%d).", lkID) } } - add(v.CreatedAt, "Заявка создана", "db", origin) + if f := globalLKFlows.Get(guid); guid != "" && f != nil && len(f.Steps) > 0 { + for _, st := range f.Steps { // путь заявки ЛК: забрана из кабинета, проверка шлюза, подмена, статусы в ЛК + add(st.At, st.Title, st.Kind, st.Detail) + } + } else { + add(v.CreatedAt, "Заявка создана", "db", origin) + } // состояния сделки M2M decided := false diff --git a/internal/lkgateway/lkemulate.go b/internal/lkgateway/lkemulate.go index d901efc..0f2b582 100644 --- a/internal/lkgateway/lkemulate.go +++ b/internal/lkgateway/lkemulate.go @@ -21,6 +21,7 @@ import ( "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock" ) @@ -94,13 +95,32 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep globalLKFlows.step(guid, id, "crane", "Проверка шлюза bj: можно исполнить", fmt.Sprintf("Сверка с DEPO: %s (инвестор %s, счёт %s).", res.Summary, pick(res.Investor, "—"), pick(res.Account, "—"))) - if _, err := svc.SendBuiltRequestVia(ctx, lkEmuSender, req, inv.ID, nil); err != nil { + // Тест «боевой ЛК → TEST3» (realtest.go): клиент из списка — в настоящий НРД с подменой инвестора. + sender := m2mcore.NSDSender(lkEmuSender) + realNSD := realTestMatch(rc, inv.Document.Series, inv.Document.Number) && svc.sender != nil + if realNSD { + asInv, asText := realTestInvestor(rc, req.Data.InvestorInformation.IdentityDocument.DocumentType) + realTestPut(RealTestRec{GUID: guid, ClaimID: id, At: time.Now(), As: asText, + Client: strings.TrimSpace(fmt.Sprintf("%s %s %s, %s %s", inv.LastName, inv.FirstName, inv.MiddleName, inv.Document.Series, inv.Document.Number))}) + sender = substituteSender{real: svc.sender, inv: asInv} + globalLKFlows.step(guid, id, "gateway", "Тест: в НРД (TEST3) — с данными тест-инвестора", + "У нас заявка проходит как клиент ("+inv.LastName+"): DEPO, QUIK МНП (боевая запись), статусы в ЛК. В НРД блок инвестора заменён на "+asText+"; GUID и референсы — настоящие.") + log.Printf("lk-gateway: ЛК #%d (%s): тест «боевой ЛК → TEST3» — в НРД как %s", id, shortGUID(guid), asText) + } + if _, err := svc.SendBuiltRequestVia(ctx, sender, req, inv.ID, nil); err != nil { if strings.Contains(err.Error(), "QUIK") { reason := "Отказ: не удалось заблокировать бумаги клиента — " + err.Error() globalLKFlows.step(guid, id, "reject", "Блокировка в QUIK не удалась", err.Error()) lkGatewayReject(ctx, rc, svc, id, inv.ID, req, "QUIK", "Отказ: не удалось заблокировать ценные бумаги", reason) return "rejected", nil } + if realNSD { + // НРД не принял пакет — заявка «Не отправлена», повтор автоматически (sendfail.go); в ЛК — «В работе» + globalLKFlows.step(guid, id, "reject", "НРД не принял пакет — повтор автоматически", err.Error()) + lkMarkSent(id) + lkLinkGUID(guid, ep, id) + return "sent", nil + } return "", fmt.Errorf("имитация отправки: %w", err) } lkMarkSent(id) @@ -110,11 +130,18 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep if devMode(rc) { sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД" } - globalLKFlows.step(guid, id, "gateway", sentTitle, - lkQuikSummary(guid)+fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay)) + sentDetail := lkQuikSummary(guid) + fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay) + if realNSD { + sentTitle, sentDetail = "Бумаги заблокированы, запрос перевода отправлен в НРД (TEST3)", lkQuikSummary(guid)+" Ждём решения контрагента от НРД." + } + globalLKFlows.step(guid, id, "gateway", sentTitle, sentDetail) if pc, pb := lkPatch(ctx, ep, fmt.Sprintf("/claims/%d", id), []byte(`{"claim":{"status":"in_process"}}`)); pc >= 400 { log.Printf("lk-gateway: ЛК #%d: PATCH «В работе» не прошёл: HTTP %d %s", id, pc, pb) } + if realNSD { + log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, отправлено в НРД TEST3 с подменой инвестора", id, shortGUID(guid)) + return "sent", nil + } log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelay) return "sent", nil } @@ -284,7 +311,13 @@ func (s *Server) applyDecisionEffects(ctx context.Context, decision *m2m.M2MTran log.Printf("lk-gateway: %s: finalize(%s) GUID=%s: %v", source, fancyStatus, decision.Header.GUID, e) } _ = note // клиенту в ЛК — только короткий итог, подробности в журнале bj - OnLKOutcome(ctx, s.rc, string(decision.Header.GUID), lkStatus, lkReason) + if realTestGet(string(decision.Header.GUID)) != nil && conf > 0 { + // тест «боевой ЛК → TEST3»: согласие контрагента — ещё не исполнение; «Исполнена» — по MS010 (realtest.go) + globalLKFlows.stepIfExists(string(decision.Header.GUID), "response", "Контрагент согласился — ждём исполнения НРД", + fmt.Sprintf("Принято %d, отклонено %d. Статус в ЛК — «В работе» до отчёта НРД об исполнении (MS010).", conf, rej)) + } else { + OnLKOutcome(ctx, s.rc, string(decision.Header.GUID), lkStatus, lkReason) + } quikOnDecision(ctx, s.rc, s.svc, decision) go depoOnCounterpartyDecision(s.rc, decision) } diff --git a/internal/lkgateway/m2mtest.go b/internal/lkgateway/m2mtest.go index e81af7e..da8dc83 100644 --- a/internal/lkgateway/m2mtest.go +++ b/internal/lkgateway/m2mtest.go @@ -332,6 +332,8 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) { OurINN string Passed, Failed, Prog, Total, Excluded int Pending int + RealNSD RealNSDTestSettings // тест «боевой ЛК → TEST3» (realtest.go) + RealNSDAs string }{ page: nowPage("Тестирование M2M", "m2mtest"), Rows: rows, CounterpartyName: plan.CounterpartyName, CounterpartyCode: plan.CounterpartyCode, @@ -339,6 +341,10 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) { OurCode: ourCode, OurName: m2mPartyName(ourCode), OurINN: ourINN, Passed: passed, Failed: failed, Prog: prog, Total: total, Excluded: excluded, Pending: total - passed - failed - prog - excluded, + RealNSD: h.rc.Snapshot().LK.RealNSDTest, + } + if l, f, m, sr, n := data.RealNSD.as(); l != "" { + data.RealNSDAs = strings.TrimSpace(l + " " + f + " " + m + ", " + sr + " " + n) } render(w, a.m2mtest, data) }) diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go index 277d411..da986ac 100644 --- a/internal/lkgateway/orderwatch.go +++ b/internal/lkgateway/orderwatch.go @@ -335,7 +335,14 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str latest = st.MS010At } } - globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At = latest }) + w = globalOrderWatch.update(guid, func(w *OrderWatch) { w.MS010At = latest }) + if direction == "out" { // тест «боевой ЛК → TEST3»: итог в ЛК — по исполнению НРД (realtest.go) + partial := "" + if total, executed, rejected, _ := w.linesSummary(); total > 1 && executed > 0 && rejected > 0 { + partial = fmt.Sprintf("исполнено %d из %d бумаг", executed, total) + } + go realTestOnExecuted(context.Background(), rc, guid, w.Rejected, partial) + } return } diff --git a/internal/lkgateway/quikcycle.go b/internal/lkgateway/quikcycle.go index 1438f5c..f194d1f 100644 --- a/internal/lkgateway/quikcycle.go +++ b/internal/lkgateway/quikcycle.go @@ -493,7 +493,7 @@ func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep { // QuikOutRegister — исходящий перевод: зарегистрировать «Вывод ЦБ (M2M)» (QUIK блокирует бумаги). func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, recipientINN, recipientName string, passExpenses bool) (*QuikLink, error) { - s := rc.Snapshot().Quik + s := quikSettingsFor(rc, guid) l := globalQuikLinks.update(guid, isin, func(l *QuikLink) { l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty }) @@ -524,7 +524,7 @@ func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, con // QuikOutSign — НРД подтвердил перевод: подписать поручение → исполнено, бумаги списаны. func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment string) (*QuikLink, error) { - s := rc.Snapshot().Quik + s := quikSettingsFor(rc, guid) l := globalQuikLinks.Get(guid, isin) if l == nil || l.InstrNo == "" { return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") @@ -558,7 +558,7 @@ func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment str // QuikOutDeny — перевод отклонён (контрагентом, НРД или оператором): снять поручение с причиной. func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason string) (*QuikLink, error) { - s := rc.Snapshot().Quik + s := quikSettingsFor(rc, guid) l := globalQuikLinks.Get(guid, isin) if l == nil || l.InstrNo == "" { return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") diff --git a/internal/lkgateway/quikhooks.go b/internal/lkgateway/quikhooks.go index ca9fdab..12cdaee 100644 --- a/internal/lkgateway/quikhooks.go +++ b/internal/lkgateway/quikhooks.go @@ -44,8 +44,11 @@ func quikSecISIN(s m2m.RequestSecurity) string { // отправку не блокируем (оповещаем); если QUIK отверг регистрацию — отправку // ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { - q := rc.Snapshot().Quik - if !quikActive(q) || !q.AutoOut || req == nil || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником + if req == nil { + return nil + } + q := quikSettingsFor(rc, string(req.Header.GUID)) // тест «боевой ЛК → TEST3» — боевая запись (realtest.go) + if !quikActive(q) || !q.AutoOut || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником return nil } guid := string(req.Header.GUID) @@ -88,11 +91,14 @@ func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2M // quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения. func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) { - q := rc.Snapshot().Quik - if !quikActive(q) || !q.AutoOut || decision == nil { + if decision == nil { return } guid := string(decision.Header.GUID) + q := quikSettingsFor(rc, guid) + if !quikActive(q) || !q.AutoOut { + return + } links := globalQuikLinks.ForGUID(guid) if len(links) == 0 { return @@ -119,7 +125,7 @@ func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decisi // quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения. func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) { - q := rc.Snapshot().Quik + q := quikSettingsFor(rc, guid) if !quikActive(q) || !q.AutoOut { return } @@ -148,13 +154,13 @@ func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string) // quikCycleTick — один проход опросчика. func quikCycleTick(ctx context.Context, rc *RuntimeConfig) { q := rc.Snapshot().Quik - if !quikActive(q) || q.Server == "" { + if (!quikActive(q) && !realTestAny()) || q.Server == "" { // тестовые заявки «боевой ЛК → TEST3» — всегда return } // исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие if q.AutoOut && globalOutgoingStore != nil { for _, od := range scanForm16Drafts(globalOutgoingStore) { - if !od.Submitted { + if !od.Submitted || (!quikActive(q) && realTestGet(od.GUID) == nil) { continue } links := globalQuikLinks.ForGUID(od.GUID) diff --git a/internal/lkgateway/realtest.go b/internal/lkgateway/realtest.go new file mode 100644 index 0000000..e2c9331 --- /dev/null +++ b/internal/lkgateway/realtest.go @@ -0,0 +1,233 @@ +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/m2m-testing/real-nsd: включить/выключить тест и список клиентов. +func registerRealNSDTest(mux *http.ServeMux, rc *RuntimeConfig) { + mux.HandleFunc("/admin/m2m-testing/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 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) + http.Redirect(w, r, "/admin/m2m-testing#real-nsd", http.StatusSeeOther) + }) +} diff --git a/internal/lkgateway/realtest_test.go b/internal/lkgateway/realtest_test.go new file mode 100644 index 0000000..b3a2a67 --- /dev/null +++ b/internal/lkgateway/realtest_test.go @@ -0,0 +1,71 @@ +package lkgateway + +import ( + "context" + "path/filepath" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" +) + +type captureSender struct{ got *m2m.M2MTransferRequest } + +func (c *captureSender) Send(_ context.Context, req *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error) { + c.got = req + return nil, nil +} +func (c *captureSender) SendDecision(context.Context, *m2m.M2MTransferDecision) error { return nil } + +func TestRealNSDTest(t *testing.T) { + old := realTestFile + realTestFile = filepath.Join(t.TempDir(), "rt.json") + realTests.loaded = false + defer func() { realTestFile, realTests.loaded = old, false }() + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + lk := rc.Snapshot().LK + lk.RealNSDTest = RealNSDTestSettings{Enabled: true, Docs: "1110 754503; 4500 000001"} + _ = rc.UpdateLK(lk) + + if !realTestMatch(rc, "1110", "754503") || !realTestMatch(rc, "11 10", "754503") || realTestMatch(rc, "1110", "000000") { + t.Fatal("выбор клиентов по паспорту") + } + + // подмена: в НРД — Бубликов, у нас запрос не меняется + req := sendFailRequest() + req.Data.InvestorInformation.LastName = "Погонщикова" + inv, as := realTestInvestor(rc, req.Data.InvestorInformation.IdentityDocument.DocumentType) + cap := &captureSender{} + if _, err := (substituteSender{real: cap, inv: inv}).Send(context.Background(), req); err != nil { + t.Fatal(err) + } + if cap.got.Data.InvestorInformation.LastName != "Бубликов" || string(cap.got.Data.InvestorInformation.IdentityDocument.DocumentNumber) != "289090" || + req.Data.InvestorInformation.LastName != "Погонщикова" || cap.got.Header.GUID != req.Header.GUID || as == "" { + t.Fatalf("подмена: ушло %+v, у нас %s", cap.got.Data.InvestorInformation, req.Data.InvestorInformation.LastName) + } + + // QUIK: боевая запись только для тестовой заявки + q := rc.Snapshot().Quik + q.Emulate, q.WritesEnabled = true, false + _ = rc.UpdateQuik(q) + guid := string(req.Header.GUID) + if s := quikSettingsFor(rc, guid); !s.Emulate || s.WritesEnabled { + t.Fatal("обычная заявка — как в настройках") + } + realTestPut(RealTestRec{GUID: guid, Client: "Погонщикова", As: as}) + if s := quikSettingsFor(rc, guid); s.Emulate || !s.WritesEnabled { + t.Fatal("тестовая заявка — боевая запись") + } + if _, ok := realTestSenderFor(rc, guid, req, cap).(substituteSender); !ok { + t.Fatal("повторная отправка тестовой заявки — тоже с подменой") + } + if !realTestAny() { + t.Fatal("есть незавершённая тестовая заявка") + } + realTestOnExecuted(context.Background(), rc, guid, false, "") + if r := realTestGet(guid); r == nil || !r.LKDone || realTestAny() { + t.Fatalf("итог в ЛК: %+v", r) + } +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index e018869..6235370 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -274,6 +274,10 @@ type LKSettings struct { // Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором. BindNetwork bool `json:"lk_bind_network,omitempty"` + // RealNSDTest — тест «боевой ЛК → тестовый контур НРД»: заявки указанных клиентов проходят всё + // по-настоящему (DEPO, QUIK МНП, статусы в ЛК), а в НРД уходят с данными тест-инвестора (realtest.go). + RealNSDTest RealNSDTestSettings `json:"real_nsd_test,omitempty"` + // Интеграция с ЛК (потребление back_office API платформы): два окружения. ActiveEnv string `json:"lk_active_env"` // "test" | "prod" Test LKEndpoint `json:"lk_test"` diff --git a/internal/lkgateway/sber100_live_test.go b/internal/lkgateway/sber100_live_test.go new file mode 100644 index 0000000..f2dcbb4 --- /dev/null +++ b/internal/lkgateway/sber100_live_test.go @@ -0,0 +1,39 @@ +//go:build live + +package lkgateway + +import ( + "context" + "fmt" + "os" + "strconv" + "testing" + "time" +) + +// Разовое зачисление в МНП «Ввод ЦБ (M2M)» для подготовки теста. Запуск от bj: +// DEP_SER=1110 DEP_NUM=754503 DEP_ISIN=RU0009029540 DEP_QTY=100 DEP_GO=1 +func TestQuikDepositLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + globalRC = rc + ctx := context.Background() + code, contract, err := quikResolveClient(ctx, rc, os.Getenv("DEP_SER"), os.Getenv("DEP_NUM")) + fmt.Println("клиент QUIK:", code, contract, err) + if err != nil || os.Getenv("DEP_GO") != "1" { + return + } + qty, _ := strconv.ParseFloat(os.Getenv("DEP_QTY"), 64) + s := rc.Snapshot().Quik + s.Emulate, s.WritesEnabled = false, true // только для этого вызова + aid, err := quikExecNewInstr(ctx, s, false, "BJ-SBER-TEST", code, os.Getenv("DEP_ISIN"), qty, "7722061076", false, + "bj: зачисление для теста M2M (боевой ЛК -> TEST3)") + fmt.Println("ActionID:", aid, err) + if err != nil { + return + } + r := quikWaitAction(ctx, s, aid, 90*time.Second) + fmt.Printf("итог МНП: %+v\n", r) +} diff --git a/internal/lkgateway/sendfail.go b/internal/lkgateway/sendfail.go index 9efdf8f..fea75f0 100644 --- a/internal/lkgateway/sendfail.go +++ b/internal/lkgateway/sendfail.go @@ -173,10 +173,10 @@ func (s *Service) ResendClaim(ctx context.Context, id string) error { guid := string(d.Request.Header.GUID) switch { case d.State == m2mcore.StateValidated: - _, err := s.SendBuiltRequest(ctx, d.Request, d.InvestorID, d.SignedClaim) + _, err := s.SendBuiltRequestVia(ctx, realTestSenderFor(globalRC, guid, d.Request, s.sender), d.Request, d.InvestorID, d.SignedClaim) return err case (d.State == m2mcore.StateAwaitingDecision || d.State == m2mcore.StateSubmittedToNSD) && sendFailGet(guid) != nil: - if _, err := s.sender.Send(ctx, d.Request); err != nil { + if _, err := realTestSenderFor(globalRC, guid, d.Request, s.sender).Send(ctx, d.Request); err != nil { s.noteSendFailure(ctx, d, d.Request, err) return err } diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index 78f946c..c6e9339 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -32,6 +32,10 @@ {{if .Flash}}
{{.Flash}}
{{end}}

Заявка {{slice .Claim.ID 0 8}} · {{ruState .Claim.Status}}{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}
{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}
{{end}}

+ {{with realTest (print .Claim.M2MGUID)}}
+ Тест «боевой ЛК → тестовый контур НРД». У нас заявка идёт как клиент: {{.Client}} — DEPO, QUIK МНП (боевая запись), статусы в ЛК. + В НРД запрос отправлен с данными тест-инвестора {{.As}}; GUID, референсы, бумаги и счета — настоящие.{{if .LKDone}} Итог передан в ЛК.{{else}} «Исполнена» в ЛК — после отчёта НРД об исполнении (MS010).{{end}} +
{{end}} {{with .Fail}}
Перевод не доставлен в НРД — {{sendFailText .}}.
Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}} diff --git a/internal/lkgateway/web/templates/admin_m2mtest.html b/internal/lkgateway/web/templates/admin_m2mtest.html index ed7eb88..bfd8d5c 100644 --- a/internal/lkgateway/web/templates/admin_m2mtest.html +++ b/internal/lkgateway/web/templates/admin_m2mtest.html @@ -62,6 +62,17 @@
{{end}} +
+

Тест «боевой ЛК → тестовый контур НРД» {{if .RealNSD.Enabled}}включён{{else}}выключен{{end}}

+

Заявки клиентов из списка (из ЛК ЕСИА Финанс) проходят у нас всё по-настоящему: проверка шлюза и поручение в DEPO, блокировка и списание в QUIK МНП (боевая запись), статусы в ЛК. В НРД (TEST3) запрос уходит с данными тест-инвестора {{.RealNSDAs}} — GUID, референсы, бумаги и счета настоящие, ответы НРД применяются к заявке клиента. «Исполнена» в ЛК — по отчёту НРД об исполнении (MS010). Остальные заявки ЛК — как раньше (без отправки в НРД).

+ {{if can "settings"}}
+ + + +
{{end}} +
+