Тест «боевой ЛК → TEST3»: заявки выбранных клиентов ЕСИА Финанс — у нас всё по-настоящему (DEPO, QUIK МНП боевая запись только для них), в НРД с подменой инвестора на тест-клиента; «Исполнена» в ЛК по MS010; повтор отправки тоже с подменой; пометка и шаги ЛК в пути заявки; включатель на «Тестирование M2M»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-29 13:10:06 +03:00
parent 340fca7bd7
commit 91d8584a9c
15 changed files with 485 additions and 21 deletions
+4 -3
View File
@@ -52,6 +52,7 @@ var templateFuncs = template.FuncMap{
"curUser": authTemplateFuncs["curUser"], "curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"], "can": authTemplateFuncs["can"],
"sendFailText": sendFailText, "sendFailText": sendFailText,
"realTest": realTestGet,
"nsdSegTip": nsdSegTip, "nsdSegTip": nsdSegTip,
"devOn": authTemplateFuncs["devOn"], "devOn": authTemplateFuncs["devOn"],
"devLicensed": authTemplateFuncs["devLicensed"], "devLicensed": authTemplateFuncs["devLicensed"],
@@ -676,9 +677,9 @@ func buildClaimTimeline(ctx context.Context, v ClaimView) []TimelineEvent {
tl = append(tl, ev) tl = append(tl, ev)
} }
// Заявка ЛК, прошедшая шлюз bj (lkflow.go): путь целиком из сохранённых шагов — // Заявка ЛК, прошедшая шлюз bj (lkflow.go), без сделки в НРД (отказ шлюза, имитатор): путь целиком
// проверка шлюза, Fancy, имитатор НРД, статус в ЛК. // из сохранённых шагов. Если заявка ушла в настоящий НРД — шаги ЛК входят в общий путь ниже.
if v.M2MGUID != "" { if v.M2MGUID != "" && realTestGet(string(v.M2MGUID)) == nil {
if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 { if f := globalLKFlows.Get(string(v.M2MGUID)); f != nil && len(f.Steps) > 0 {
var prev time.Time var prev time.Time
for _, st := range f.Steps { for _, st := range f.Steps {
+43
View File
@@ -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)
}
}
+7 -1
View File
@@ -130,7 +130,13 @@ func claimTimelineFull(ctx context.Context, v ClaimView) []TimelineEvent {
origin = fmt.Sprintf("Заявка из личного кабинета ЕСИА Финанс (заявка ЛК #%d).", lkID) 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 // состояния сделки M2M
decided := false decided := false
+37 -4
View File
@@ -21,6 +21,7 @@ import (
"time" "time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" "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" "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: можно исполнить", globalLKFlows.step(guid, id, "crane", "Проверка шлюза bj: можно исполнить",
fmt.Sprintf("Сверка с DEPO: %s (инвестор %s, счёт %s).", res.Summary, pick(res.Investor, "—"), pick(res.Account, "—"))) 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") { if strings.Contains(err.Error(), "QUIK") {
reason := "Отказ: не удалось заблокировать бумаги клиента — " + err.Error() reason := "Отказ: не удалось заблокировать бумаги клиента — " + err.Error()
globalLKFlows.step(guid, id, "reject", "Блокировка в QUIK не удалась", err.Error()) globalLKFlows.step(guid, id, "reject", "Блокировка в QUIK не удалась", err.Error())
lkGatewayReject(ctx, rc, svc, id, inv.ID, req, "QUIK", "Отказ: не удалось заблокировать ценные бумаги", reason) lkGatewayReject(ctx, rc, svc, id, inv.ID, req, "QUIK", "Отказ: не удалось заблокировать ценные бумаги", reason)
return "rejected", nil 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) return "", fmt.Errorf("имитация отправки: %w", err)
} }
lkMarkSent(id) lkMarkSent(id)
@@ -110,11 +130,18 @@ func lkProcessEmulated(ctx context.Context, rc *RuntimeConfig, svc *Service, ep
if devMode(rc) { if devMode(rc) {
sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД" sentTitle = "Бумаги заблокированы, заявка передана имитатору НРД"
} }
globalLKFlows.step(guid, id, "gateway", sentTitle, sentDetail := lkQuikSummary(guid) + fmt.Sprintf(" В НРД не отправлялась; ответ контрагента — через %s.", lkEmuDecisionDelay)
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 { 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) 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) log.Printf("lk-gateway: ЛК #%d (%s): сверка пройдена, бумаги заблокированы, заявка передана имитатору НРД — решение контрагента через %s", id, shortGUID(guid), lkEmuDecisionDelay)
return "sent", nil 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) log.Printf("lk-gateway: %s: finalize(%s) GUID=%s: %v", source, fancyStatus, decision.Header.GUID, e)
} }
_ = note // клиенту в ЛК — только короткий итог, подробности в журнале bj _ = 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) quikOnDecision(ctx, s.rc, s.svc, decision)
go depoOnCounterpartyDecision(s.rc, decision) go depoOnCounterpartyDecision(s.rc, decision)
} }
+6
View File
@@ -332,6 +332,8 @@ func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
OurINN string OurINN string
Passed, Failed, Prog, Total, Excluded int Passed, Failed, Prog, Total, Excluded int
Pending int Pending int
RealNSD RealNSDTestSettings // тест «боевой ЛК → TEST3» (realtest.go)
RealNSDAs string
}{ }{
page: nowPage("Тестирование M2M", "m2mtest"), Rows: rows, page: nowPage("Тестирование M2M", "m2mtest"), Rows: rows,
CounterpartyName: plan.CounterpartyName, CounterpartyCode: plan.CounterpartyCode, 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, OurCode: ourCode, OurName: m2mPartyName(ourCode), OurINN: ourINN,
Passed: passed, Failed: failed, Prog: prog, Total: total, Excluded: excluded, Passed: passed, Failed: failed, Prog: prog, Total: total, Excluded: excluded,
Pending: total - passed - failed - prog - 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) render(w, a.m2mtest, data)
}) })
+8 -1
View File
@@ -335,7 +335,14 @@ func orderWatchCheck(ctx context.Context, rc *RuntimeConfig, guid, direction str
latest = st.MS010At 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 return
} }
+3 -3
View File
@@ -493,7 +493,7 @@ func quikStep(action string, aid int64, r QuikActionResult, ok bool) QuikStep {
// QuikOutRegister — исходящий перевод: зарегистрировать «Вывод ЦБ (M2M)» (QUIK блокирует бумаги). // QuikOutRegister — исходящий перевод: зарегистрировать «Вывод ЦБ (M2M)» (QUIK блокирует бумаги).
func QuikOutRegister(ctx context.Context, rc *RuntimeConfig, guid, clientID, contract, isin string, qty float64, recipientINN, recipientName string, passExpenses bool) (*QuikLink, error) { 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 := globalQuikLinks.update(guid, isin, func(l *QuikLink) {
l.Direction, l.ClientID, l.Contract, l.ISIN, l.Qty = "out", clientID, contract, isin, qty 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 — НРД подтвердил перевод: подписать поручение → исполнено, бумаги списаны. // QuikOutSign — НРД подтвердил перевод: подписать поручение → исполнено, бумаги списаны.
func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment string) (*QuikLink, error) { 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) l := globalQuikLinks.Get(guid, isin)
if l == nil || l.InstrNo == "" { if l == nil || l.InstrNo == "" {
return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") return l, errors.New("нет зарегистрированного поручения МНП по этому переводу")
@@ -558,7 +558,7 @@ func QuikOutSign(ctx context.Context, rc *RuntimeConfig, guid, isin, comment str
// QuikOutDeny — перевод отклонён (контрагентом, НРД или оператором): снять поручение с причиной. // QuikOutDeny — перевод отклонён (контрагентом, НРД или оператором): снять поручение с причиной.
func QuikOutDeny(ctx context.Context, rc *RuntimeConfig, guid, isin, reason string) (*QuikLink, error) { 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) l := globalQuikLinks.Get(guid, isin)
if l == nil || l.InstrNo == "" { if l == nil || l.InstrNo == "" {
return l, errors.New("нет зарегистрированного поручения МНП по этому переводу") return l, errors.New("нет зарегистрированного поручения МНП по этому переводу")
+13 -7
View File
@@ -44,8 +44,11 @@ func quikSecISIN(s m2m.RequestSecurity) string {
// отправку не блокируем (оповещаем); если QUIK отверг регистрацию — отправку // отправку не блокируем (оповещаем); если QUIK отверг регистрацию — отправку
// ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано). // ОТМЕНЯЕМ (нельзя переводить то, что не заблокировано).
func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error { func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) error {
q := rc.Snapshot().Quik if req == nil {
if !quikActive(q) || !q.AutoOut || req == nil || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником return nil
}
q := quikSettingsFor(rc, string(req.Header.GUID)) // тест «боевой ЛК → TEST3» — боевая запись (realtest.go)
if !quikActive(q) || !q.AutoOut || manualQuikSkipped(ctx) { // ручной ввод: заблокировано сотрудником
return nil return nil
} }
guid := string(req.Header.GUID) guid := string(req.Header.GUID)
@@ -88,11 +91,14 @@ func quikOutgoingBeforeSend(ctx context.Context, rc *RuntimeConfig, req *m2m.M2M
// quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения. // quikOnDecision — ответ контрагента: по отказанным бумагам снимаем поручения.
func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) { func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decision *m2m.M2MTransferDecision) {
q := rc.Snapshot().Quik if decision == nil {
if !quikActive(q) || !q.AutoOut || decision == nil {
return return
} }
guid := string(decision.Header.GUID) guid := string(decision.Header.GUID)
q := quikSettingsFor(rc, guid)
if !quikActive(q) || !q.AutoOut {
return
}
links := globalQuikLinks.ForGUID(guid) links := globalQuikLinks.ForGUID(guid)
if len(links) == 0 { if len(links) == 0 {
return return
@@ -119,7 +125,7 @@ func quikOnDecision(ctx context.Context, rc *RuntimeConfig, svc *Service, decisi
// quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения. // quikOnServiceError — сервис МОСТ отверг перевод целиком: снимаем все поручения.
func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) { func quikOnServiceError(ctx context.Context, rc *RuntimeConfig, guid, reason string) {
q := rc.Snapshot().Quik q := quikSettingsFor(rc, guid)
if !quikActive(q) || !q.AutoOut { if !quikActive(q) || !q.AutoOut {
return return
} }
@@ -148,13 +154,13 @@ func orderRespVerdict(resps []orderResponse) (done, rejected bool, text string)
// quikCycleTick — один проход опросчика. // quikCycleTick — один проход опросчика.
func quikCycleTick(ctx context.Context, rc *RuntimeConfig) { func quikCycleTick(ctx context.Context, rc *RuntimeConfig) {
q := rc.Snapshot().Quik q := rc.Snapshot().Quik
if !quikActive(q) || q.Server == "" { if (!quikActive(q) && !realTestAny()) || q.Server == "" { // тестовые заявки «боевой ЛК → TEST3» — всегда
return return
} }
// исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие // исходящие: наше поручение 16 принято НРД → подпись; отказ → снятие
if q.AutoOut && globalOutgoingStore != nil { if q.AutoOut && globalOutgoingStore != nil {
for _, od := range scanForm16Drafts(globalOutgoingStore) { for _, od := range scanForm16Drafts(globalOutgoingStore) {
if !od.Submitted { if !od.Submitted || (!quikActive(q) && realTestGet(od.GUID) == nil) {
continue continue
} }
links := globalQuikLinks.ForGUID(od.GUID) links := globalQuikLinks.ForGUID(od.GUID)
+233
View File
@@ -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)
})
}
+71
View File
@@ -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)
}
}
+4
View File
@@ -274,6 +274,10 @@ type LKSettings struct {
// Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором. // Строже, но мобильных клиентов будет выбрасывать при смене адреса оператором.
BindNetwork bool `json:"lk_bind_network,omitempty"` BindNetwork bool `json:"lk_bind_network,omitempty"`
// RealNSDTest — тест «боевой ЛК → тестовый контур НРД»: заявки указанных клиентов проходят всё
// по-настоящему (DEPO, QUIK МНП, статусы в ЛК), а в НРД уходят с данными тест-инвестора (realtest.go).
RealNSDTest RealNSDTestSettings `json:"real_nsd_test,omitempty"`
// Интеграция с ЛК (потребление back_office API платформы): два окружения. // Интеграция с ЛК (потребление back_office API платформы): два окружения.
ActiveEnv string `json:"lk_active_env"` // "test" | "prod" ActiveEnv string `json:"lk_active_env"` // "test" | "prod"
Test LKEndpoint `json:"lk_test"` Test LKEndpoint `json:"lk_test"`
+39
View File
@@ -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)
}
+2 -2
View File
@@ -173,10 +173,10 @@ func (s *Service) ResendClaim(ctx context.Context, id string) error {
guid := string(d.Request.Header.GUID) guid := string(d.Request.Header.GUID)
switch { switch {
case d.State == m2mcore.StateValidated: 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 return err
case (d.State == m2mcore.StateAwaitingDecision || d.State == m2mcore.StateSubmittedToNSD) && sendFailGet(guid) != nil: 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) s.noteSendFailure(ctx, d, d.Request, err)
return err return err
} }
@@ -32,6 +32,10 @@
{{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}} {{if .Flash}}<div class="card" style="background:var(--accent-weak,#e7f0fb)">{{.Flash}}</div>{{end}}
<div class="card"> <div class="card">
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/resend" style="margin:0 0 0 auto" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Отправляем…';"><button type="submit" class="btn">Отправить повторно</button></form>{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0{{if not (or .Fail (eq .Claim.Status "validated"))}} 0 0 auto{{end}}" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">{{if eq .Claim.Status "draft"}}Удалить черновик{{else}}Удалить{{end}}</button></form>{{end}}</h2> <h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/resend" style="margin:0 0 0 auto" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Отправляем…';"><button type="submit" class="btn">Отправить повторно</button></form>{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0{{if not (or .Fail (eq .Claim.Status "validated"))}} 0 0 auto{{end}}" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">{{if eq .Claim.Status "draft"}}Удалить черновик{{else}}Удалить{{end}}</button></form>{{end}}</h2>
{{with realTest (print .Claim.M2MGUID)}}<div class="card" style="background:var(--warn-weak,#fbf3d9);margin:8px 0">
<b>Тест «боевой ЛК → тестовый контур НРД»</b>. У нас заявка идёт как клиент: <b>{{.Client}}</b> — DEPO, QUIK МНП (боевая запись), статусы в ЛК.
В НРД запрос отправлен с данными тест-инвестора <b>{{.As}}</b>; GUID, референсы, бумаги и счета — настоящие.{{if .LKDone}} Итог передан в ЛК.{{else}} «Исполнена» в ЛК — после отчёта НРД об исполнении (MS010).{{end}}
</div>{{end}}
{{with .Fail}}<div class="card" style="background:var(--err-weak,#fbe9e7);margin:8px 0"> {{with .Fail}}<div class="card" style="background:var(--err-weak,#fbe9e7);margin:8px 0">
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br> <b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
<span class="muted">Попытка {{.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}}</span> <span class="muted">Попытка {{.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}}</span>
@@ -62,6 +62,17 @@
</div> </div>
{{end}} {{end}}
<div class="card" id="real-nsd" style="margin:10px 0;{{if .RealNSD.Enabled}}background:var(--warn-weak,#fbf3d9){{end}}">
<h3 style="margin:0 0 6px;font-size:15px">Тест «боевой ЛК → тестовый контур НРД» {{if .RealNSD.Enabled}}<span class="badge awaiting_decision">включён</span>{{else}}<span class="badge draft">выключен</span>{{end}}</h3>
<p class="muted" style="margin:0 0 8px;font-size:13px">Заявки клиентов из списка (из ЛК ЕСИА Финанс) проходят у нас всё по-настоящему: проверка шлюза и поручение в DEPO, блокировка и списание в QUIK МНП (<b>боевая запись</b>), статусы в ЛК. В НРД (TEST3) запрос уходит с данными тест-инвестора <b>{{.RealNSDAs}}</b> — GUID, референсы, бумаги и счета настоящие, ответы НРД применяются к заявке клиента. «Исполнена» в ЛК — по отчёту НРД об исполнении (MS010). Остальные заявки ЛК — как раньше (без отправки в НРД).</p>
{{if can "settings"}}<form method="post" action="/admin/m2m-testing/real-nsd" class="cpf" onsubmit="return !this.enabled.checked || confirm('Включить? Для клиентов из списка QUIK МНП будет записывать по-настоящему (блокировка и списание бумаг).');">
<label style="flex-direction:row;align-items:center;gap:6px;font-size:13px;color:var(--text)"><input type="checkbox" name="enabled" style="min-width:0"{{if .RealNSD.Enabled}} checked{{end}}> включить</label>
<label style="min-width:280px">Паспорта клиентов (серия номер, через «;»)
<input name="docs" value="{{.RealNSD.Docs}}" placeholder="1110 754503"></label>
<button class="b-init" style="background:var(--card);border-color:var(--border);color:var(--text)">Сохранить</button>
</form>{{end}}
</div>
<form method="post" action="/admin/m2m-testing/counterparty" class="cpf" id="cpForm"> <form method="post" action="/admin/m2m-testing/counterparty" class="cpf" id="cpForm">
<label style="min-width:340px">Контрагент (из справочника M2M) <label style="min-width:340px">Контрагент (из справочника M2M)
<input id="cp-search" list="cp-list" autocomplete="off" placeholder="начните вводить наименование или код…" <input id="cp-search" list="cp-list" autocomplete="off" placeholder="начните вводить наименование или код…"