ЛК: итог «Исполнена» по MS010, GUID без учёта регистра, заявка при отказе МНП берётся один раз; тестовый контур не трогает боевой ЛК

Перевод 97EAD00C (заявка ЛК #71741, 23.10.2026) исполнен в QUIK и НРД (MS010),
но в ЛК статус не изменился, а сделка осталась «Подтверждена»:
- связки guid → заявка ЛК искались с учётом регистра: мы шлём GUID строчными,
  НРД отвечает заглавными — OnLKOutcome молча не находил заявку. Ключи —
  строчными (lkGUIDKey), старые записи приводятся при загрузке; итог модуля
  ЛК (lk.transfer_requests) — lower(guid);
- после MS010 по всем строкам поручения 16 сделка → «Исполнена» (CompleteSUB16),
  итог — в ЛК (outOnExecuted, каждый проход order-watch, повтор безвреден);
  в бою согласие контрагента — ещё не «Исполнена»: статус ждёт MS010, как в
  тесте «боевой ЛК → TEST3»; имитатор НРД — итог сразу.

Тестовый 28 с боевым ЛК взял ту же заявку и после отказа МНП («запрещен для
операции шорт» — бумагу уже заблокировал 9) брал её каждые 10 секунд: 108
сделок с новыми GUID и столько же писем.
- Сделка заведена, а отправка не прошла (МНП не заблокировал) — заявка
  помечается взятой и связывается с переводом: дальше — оператор в карточке.
- Боевой ЛК при тестовом профиле ИШ (lkProdOnTestNSD): заявки клиентов не
  берутся (кроме теста «боевой ЛК → тестовый НРД»), портфели не заполняются,
  предупреждение в настройках ЛК.
- Письма: в теме и первой строке — сервер bj и контур НРД («[bj ТЕСТ …]»
  у тестового); письма о входящих — метка только у тестового.
- История FSM — по МСК с датой, «текущее состояние» вместо «сейчас».
- Карточка теста «боевой ЛК → тестовый НРД» — только в режиме разработки
  (включённый тест виден всегда, чтобы его выключить).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-23 16:04:44 +03:00
parent ffd69b0698
commit 32d88fc42a
11 changed files with 333 additions and 20 deletions
+8
View File
@@ -67,6 +67,7 @@ var templateFuncs = template.FuncMap{
"quikRecipientsInfo": quikRecipientsInfo, "quikRecipientsInfo": quikRecipientsInfo,
"quikNeedsTrust": quikNeedsTrust, "quikNeedsTrust": quikNeedsTrust,
"lkPortfolioSummary": lkPortfolioSummary, "lkPortfolioSummary": lkPortfolioSummary,
"lkProdOnTestNSD": func() bool { return lkProdOnTestNSD(globalRC) },
"backupStatus": backupStatus, "backupStatus": backupStatus,
"backupWhen": backupWhen, "backupWhen": backupWhen,
"smbWin": smbWin, "smbWin": smbWin,
@@ -247,6 +248,13 @@ var templateFuncs = template.FuncMap{
} }
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
}, },
// mskMs — момент по МСК с миллисекундами (история FSM: время хранится в UTC).
"mskMs": func(t time.Time) string {
if t.IsZero() {
return "—"
}
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05.000")
},
// transit — длительность между двумя моментами (для «времени прохождения»). // transit — длительность между двумя моментами (для «времени прохождения»).
"transit": func(a, b time.Time) string { "transit": func(a, b time.Time) string {
if a.IsZero() || b.IsZero() { if a.IsZero() || b.IsZero() {
+20 -1
View File
@@ -32,7 +32,8 @@ func dispatchAlert(rc *RuntimeConfig, title, body string) {
s := a.SMTP s := a.SMTP
s.To = notifyRecipients(cfg, alertKind(title)) s.To = notifyRecipients(cfg, alertKind(title))
if strings.TrimSpace(s.To) != "" { if strings.TrimSpace(s.To) != "" {
if err := sendSMTP(s, "[bj] "+title, body); err != nil { tag, line := alertServer(cfg.NSD.Profile)
if err := sendSMTP(s, tag+title, line+body); err != nil {
log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err) log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err)
} }
} }
@@ -46,6 +47,24 @@ func dispatchAlert(rc *RuntimeConfig, title, body string) {
} }
} }
// alertServer — от какого сервера bj письмо: метка в теме и строка в начале текста (23.10.2026: письмо
// тестового сервера о заявке клиента приняли за письмо боевого).
func alertServer(profile string) (tag, line string) {
host := primaryIPv4()
contour := "боевой контур НРД"
tag = "[bj " + host + "] "
if !nsdProfileProd(profile) {
contour = "ТЕСТОВЫЙ контур НРД"
tag = "[bj ТЕСТ " + host + "] "
}
return tag, fmt.Sprintf("Сервер bj: %s (%s, профиль %s)\n\n", adminBaseURL(), contour, pickStr(profile, "не задан"))
}
// nsdProfileProd — профиль ИШ боевого контура НРД (prod-gost); test3-… и пустой — тестовый.
func nsdProfileProd(profile string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(profile)), "prod")
}
// --- SMTP --- // --- SMTP ---
// sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования, // sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования,
+10 -1
View File
@@ -33,6 +33,10 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) {
} }
subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт" subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт"
body := buildIncomingEmailBody(it) body := buildIncomingEmailBody(it)
if !nsdProfileProd(cfg.NSD.Profile) { // письмо тестового сервера не принять за боевое
tag, line := alertServer(cfg.NSD.Profile)
subject, body = tag+subject, line+body
}
s := smtp s := smtp
s.To = notifyRecipients(cfg, "incoming") // получатели по маршруту «входящие переводы» s.To = notifyRecipients(cfg, "incoming") // получатели по маршруту «входящие переводы»
if err := sendSMTP(s, subject, body); err != nil { if err := sendSMTP(s, subject, body); err != nil {
@@ -95,7 +99,12 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed
m := smtp m := smtp
m.To = notifyRecipients(cfg, "incoming") m.To = notifyRecipients(cfg, "incoming")
if err := sendSMTP(m, subject, b.String()); err != nil { text := b.String()
if !nsdProfileProd(cfg.NSD.Profile) { // письмо тестового сервера не принять за боевое
tag, line := alertServer(cfg.NSD.Profile)
subject, text = tag+subject, line+text
}
if err := sendSMTP(m, subject, text); err != nil {
log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err) log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err)
return return
} }
+84 -10
View File
@@ -39,7 +39,7 @@ func lkClaimInvestorKey(cl map[string]any) string {
// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по // lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по
// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт. // ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт.
const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json" var lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json" // var — тесты подменяют путь
var ( var (
lkLinksMu sync.Mutex lkLinksMu sync.Mutex
@@ -47,10 +47,24 @@ var (
lkLoaded bool lkLoaded bool
) )
// lkGUIDKey — ключ связок по GUID без учёта регистра: мы шлём GUID строчными, НРД отвечает
// заглавными (23.10.2026: по ответу 97EAD00C-… связка 97ead00c-… не нашлась — статус в ЛК не ушёл).
func lkGUIDKey(guid string) string { return strings.ToLower(strings.TrimSpace(guid)) }
// lkNormKeys — ключи связок из файла к одному регистру (записи до 23.10.2026).
func lkNormKeys(m map[string]int) {
for k, v := range m {
if n := lkGUIDKey(k); n != k {
delete(m, k)
m[n] = v
}
}
}
func lkLinkGUID(guid string, _ LKEndpoint, claimID int) { func lkLinkGUID(guid string, _ LKEndpoint, claimID int) {
lkLinksMu.Lock() lkLinksMu.Lock()
lkEnsureLinksLoaded() lkEnsureLinksLoaded()
lkLinks[guid] = claimID lkLinks[lkGUIDKey(guid)] = claimID
if raw, err := json.Marshal(lkLinks); err == nil { if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600) _ = os.WriteFile(lkLinksFile, raw, 0o600)
} }
@@ -67,11 +81,12 @@ func lkEnsureLinksLoaded() {
if raw, err := os.ReadFile(lkLinksFile); err == nil { if raw, err := os.ReadFile(lkLinksFile); err == nil {
_ = json.Unmarshal(raw, &lkLinks) _ = json.Unmarshal(raw, &lkLinks)
} }
lkNormKeys(lkLinks)
} }
// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI // lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI
// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся. // «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся.
const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json" var lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
var ( var (
lkOriginMu sync.Mutex lkOriginMu sync.Mutex
@@ -84,6 +99,7 @@ func lkEnsureOriginLoaded() {
return return
} }
lkOriginLoaded = true lkOriginLoaded = true
defer lkNormKeys(lkOrigin)
if raw, err := os.ReadFile(lkOriginFile); err == nil { if raw, err := os.ReadFile(lkOriginFile); err == nil {
_ = json.Unmarshal(raw, &lkOrigin) _ = json.Unmarshal(raw, &lkOrigin)
return return
@@ -98,7 +114,7 @@ func lkRecordOrigin(guid string, claimID int) {
lkOriginMu.Lock() lkOriginMu.Lock()
defer lkOriginMu.Unlock() defer lkOriginMu.Unlock()
lkEnsureOriginLoaded() lkEnsureOriginLoaded()
lkOrigin[guid] = claimID lkOrigin[lkGUIDKey(guid)] = claimID
if raw, err := json.Marshal(lkOrigin); err == nil { if raw, err := json.Marshal(lkOrigin); err == nil {
_ = os.WriteFile(lkOriginFile, raw, 0o600) _ = os.WriteFile(lkOriginFile, raw, 0o600)
} }
@@ -110,10 +126,19 @@ func LKClaimIDByGUID(guid string) (int, bool) {
lkOriginMu.Lock() lkOriginMu.Lock()
defer lkOriginMu.Unlock() defer lkOriginMu.Unlock()
lkEnsureOriginLoaded() lkEnsureOriginLoaded()
id, ok := lkOrigin[guid] id, ok := lkOrigin[lkGUIDKey(guid)]
return id, ok return id, ok
} }
// lkLinked — заявка ЛК ещё ждёт итогового статуса (связка не закрыта OnLKOutcome).
func lkLinked(guid string) bool {
lkLinksMu.Lock()
defer lkLinksMu.Unlock()
lkEnsureLinksLoaded()
_, ok := lkLinks[lkGUIDKey(guid)]
return ok
}
// lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей // lkSent — id заявок ЛК, уже отправленных в НРД (at-most-once защита от дублей
// переводов при повторных проходах поллера). Переживает рестарт. // переводов при повторных проходах поллера). Переживает рестарт.
const lkSentFile = "/var/lib/bj/.bj/lk-sent.json" const lkSentFile = "/var/lib/bj/.bj/lk-sent.json"
@@ -163,9 +188,9 @@ func lkMarkSent(id int) {
func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) { func OnLKOutcome(ctx context.Context, rc *RuntimeConfig, guid, status, reason string) {
lkLinksMu.Lock() lkLinksMu.Lock()
lkEnsureLinksLoaded() lkEnsureLinksLoaded()
id, ok := lkLinks[guid] id, ok := lkLinks[lkGUIDKey(guid)]
if ok { if ok {
delete(lkLinks, guid) delete(lkLinks, lkGUIDKey(guid))
if raw, err := json.Marshal(lkLinks); err == nil { if raw, err := json.Marshal(lkLinks); err == nil {
_ = os.WriteFile(lkLinksFile, raw, 0o600) _ = os.WriteFile(lkLinksFile, raw, 0o600)
} }
@@ -345,7 +370,7 @@ func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullR
continue // чужой вид/статус — не наша заявка continue // чужой вид/статус — не наша заявка
} }
id := toInt(c["id"]) id := toInt(c["id"])
if id == 0 || lkAlreadySent(id) { if id == 0 || lkAlreadySent(id) || lkTestSkipped(id) {
continue continue
} }
res.Fetched++ res.Fetched++
@@ -354,6 +379,10 @@ func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullR
res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err)) res.Errors = append(res.Errors, fmt.Sprintf("#%d: %v", id, err))
continue continue
} }
if outcome == "skipped" {
res.Fetched--
continue
}
if outcome == "rejected" { if outcome == "rejected" {
res.Rejected++ res.Rejected++
} else { } else {
@@ -385,6 +414,13 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
if err != nil { if err != nil {
return "", err return "", err
} }
if lkProdOnTestNSD(rc) && !realTestMatch(rc, claimReq.Investor.Document.Series, claimReq.Investor.Document.Number) {
// Заявка клиента — для боевого сервера: здесь её не трогаем (ни QUIK, ни DEPO, ни статус в ЛК).
lkTestSkip(id)
log.Printf("lk-gateway: ЛК #%d: заявка клиента не взята — боевой ЛК на сервере с тестовым контуром НРД (%s); берутся только заявки теста «боевой ЛК → тестовый НРД»",
id, rc.Snapshot().NSD.Profile)
return "skipped", nil
}
// Регистрируем клиента в справочнике (как для ЛК-подачи). // Регистрируем клиента в справочнике (как для ЛК-подачи).
if ss, ok := svc.store.(*SeedStore); ok { if ss, ok := svc.store.(*SeedStore); ok {
nsd := rc.Snapshot().NSD nsd := rc.Snapshot().NSD
@@ -427,8 +463,16 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id) return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id)
} }
if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil { if _, err := svc.SendBuiltRequest(ctx, req, claimReq.Investor.ID, nil); err != nil {
// Отправка не удалась (напр. до реального Send в НРД) — НЕ помечаем // Сделка уже заведена (напр. QUIK МНП не заблокировал бумаги) — заявку берём один раз:
// отправленной, чтобы можно было повторить после устранения причины. // дальше перевод повторяет или отклоняет оператор в карточке, итог уйдёт в ЛК по связке.
// Иначе каждый проход заводил новую сделку с новым GUID и новым письмом (23.10.2026:
// 108 повторов заявки #71741 за 20 минут).
if d, gerr := svc.Repo().GetByGUID(ctx, req.Header.GUID); gerr == nil && d != nil {
lkMarkSent(id)
lkLinkGUID(string(req.Header.GUID), ep, id)
return "", fmt.Errorf("отправка в НРД: %w — перевод %s ждёт оператора в карточке, повторно заявка не берётся", err, shortGUID(string(req.Header.GUID)))
}
// Сделки нет (сбой до её заведения) — НЕ помечаем, повторим после устранения причины.
return "", fmt.Errorf("отправка в НРД: %w", err) return "", fmt.Errorf("отправка в НРД: %w", err)
} }
// Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль. // Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль.
@@ -443,6 +487,36 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
return "sent", nil return "sent", nil
} }
// lkProdOnTestNSD — боевой ЛК на сервере с тестовым контуром НРД (23.10.2026: тестовый 28 с боевым ЛК
// брал заявки клиентов одновременно с боевым 9 и пытался заблокировать те же бумаги в МНП). Такой
// сервер берёт только заявки теста «боевой ЛК → тестовый НРД» и не заполняет портфели в ЛК.
func lkProdOnTestNSD(rc *RuntimeConfig) bool {
if rc == nil {
return false
}
s := rc.Snapshot()
return s.LK.ActiveEnv == "prod" && !nsdProfileProd(s.NSD.Profile)
}
// Заявки ЛК, не взятые сервером с тестовым контуром (lkProdOnTestNSD), — чтобы не запрашивать их
// каждые 10 секунд. В памяти: после перезапуска (и смены настроек) проверяются заново.
var lkTestSkips = struct {
sync.Mutex
m map[int]bool
}{m: map[int]bool{}}
func lkTestSkip(id int) {
lkTestSkips.Lock()
lkTestSkips.m[id] = true
lkTestSkips.Unlock()
}
func lkTestSkipped(id int) bool {
lkTestSkips.Lock()
defer lkTestSkips.Unlock()
return lkTestSkips.m[id]
}
// lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest. // lkClaimToRequest маппит заявку ЕСИА Финанс → наш CreateClaimRequest.
func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) { func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) {
var req CreateClaimRequest var req CreateClaimRequest
+3 -2
View File
@@ -337,8 +337,9 @@ 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
if realTestGet(string(decision.Header.GUID)) != nil && conf > 0 { if (realTestGet(string(decision.Header.GUID)) != nil || source == "поллер") && conf > 0 {
// тест «боевой ЛК → TEST3»: согласие контрагента — ещё не исполнение; «Исполнена» — по MS010 (realtest.go) // согласие контрагента — ещё не исполнение: «Исполнена» в ЛК — по MS010 на наше поручение 16
// (outOnExecuted, тест «боевой ЛК → TEST3» — realtest.go); имитатор НРД MS010 не шлёт — итог сразу
globalLKFlows.stepIfExists(string(decision.Header.GUID), "response", "Контрагент согласился — ждём исполнения НРД", globalLKFlows.stepIfExists(string(decision.Header.GUID), "response", "Контрагент согласился — ждём исполнения НРД",
fmt.Sprintf("Принято %d, отклонено %d. Статус в ЛК — «В работе» до отчёта НРД об исполнении (MS010).", conf, rej)) fmt.Sprintf("Принято %d, отклонено %d. Статус в ЛК — «В работе» до отчёта НРД об исполнении (MS010).", conf, rej))
} else { } else {
+148
View File
@@ -0,0 +1,148 @@
package lkgateway
import (
"context"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// lkLinksTemp — связки ЛК во временном каталоге, состояние в памяти сброшено.
func lkLinksTemp(t *testing.T, links string) {
t.Helper()
dir := t.TempDir()
oldL, oldO, oldR := lkLinksFile, lkOriginFile, realTestFile
lkLinksFile, lkOriginFile, realTestFile = filepath.Join(dir, "links.json"), filepath.Join(dir, "origin.json"), filepath.Join(dir, "realtest.json")
if links != "" {
if err := os.WriteFile(lkLinksFile, []byte(links), 0o600); err != nil {
t.Fatal(err)
}
}
reset := func() {
lkLinksMu.Lock()
lkLinks, lkLoaded = map[string]int{}, false
lkLinksMu.Unlock()
lkOriginMu.Lock()
lkOrigin, lkOriginLoaded = map[string]int{}, false
lkOriginMu.Unlock()
realTests.Lock()
realTests.loaded, realTests.m = false, nil
realTests.Unlock()
}
reset()
t.Cleanup(func() {
lkLinksFile, lkOriginFile, realTestFile = oldL, oldO, oldR
reset()
})
}
// НРД отвечает GUID заглавными, мы шлём строчными (23.10.2026: статус в ЛК по 97EAD00C не ушёл).
func TestLKLinksIgnoreGUIDCase(t *testing.T) {
lkLinksTemp(t, `{"97ead00c-fd0d-49ab-85a1-15eb49f3201a":71741}`) // запись до исправления
up := "97EAD00C-FD0D-49AB-85A1-15EB49F3201A"
if !lkLinked(up) {
t.Fatal("связка не найдена по GUID заглавными")
}
lkRecordOrigin("97ead00c-fd0d-49ab-85a1-15eb49f3201a", 71741)
if id, ok := LKClaimIDByGUID(up); !ok || id != 71741 {
t.Fatalf("происхождение по GUID заглавными: %d %v", id, ok)
}
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
OnLKOutcome(context.Background(), rc, up, "done", "Перевод исполнен") // ЛК не настроен — только закрытие связки
if lkLinked(up) {
t.Fatal("после итога связка должна закрыться")
}
}
// MS010 по нашему поручению 16: сделка «Подтверждена» → «Исполнена», итог — в ЛК.
func TestOutOnExecuted(t *testing.T) {
lkLinksTemp(t, "")
ctx := context.Background()
repo := m2mcore.NewMemoryRepository()
svc := NewService(Config{Repository: repo, Sender: &flakySender{}})
guid := "c02a1d5e-c2af-4799-bab4-953f133c5133"
req := sendFailRequest()
now := time.Now()
if _, err := repo.Create(ctx, &m2mcore.Deal{ID: "d1", GUID: m2m.UUID(guid), State: m2mcore.StateConfirmed, Request: req, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
lkLinkGUID(guid, LKEndpoint{}, 71741)
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
w := &OrderWatch{GUID: guid, Direction: "out", MS010At: now, Lines: map[string]*OrderLineWatch{"42391": {ISIN: "RU0009029540", MS010At: now}}}
outOnExecuted(ctx, rc, svc, guid, w)
d, _ := repo.GetByGUID(ctx, m2m.UUID(guid))
if d.State != m2mcore.StateDone {
t.Fatalf("сделка: %s, нужно done", d.State)
}
if lkLinked(guid) {
t.Fatal("итог в ЛК не передан — связка не закрыта")
}
outOnExecuted(ctx, rc, svc, guid, w) // повтор каждый проход — без ошибок и изменений
if d, _ := repo.GetByGUID(ctx, m2m.UUID(guid)); d.State != m2mcore.StateDone {
t.Fatalf("после повтора: %s", d.State)
}
}
// Тестовый контур НРД с боевым ЛК: заявки клиентов не берутся, портфели не заполняются.
func TestLKProdOnTestNSD(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
set := func(env, profile string) bool {
lk := rc.Snapshot().LK
lk.ActiveEnv = env
if err := rc.UpdateLK(lk); err != nil {
t.Fatal(err)
}
n := rc.Snapshot().NSD
n.Profile = profile
if err := rc.UpdateNSD(n); err != nil {
t.Fatal(err)
}
return lkProdOnTestNSD(rc)
}
if !set("prod", "test3-gost") || set("prod", "prod-gost") || set("test", "test3-gost") {
t.Fatal("боевой ЛК + тестовый контур — только эта пара")
}
if tag, line := alertServer("test3-gost"); !strings.HasPrefix(tag, "[bj ТЕСТ ") || !strings.Contains(line, "ТЕСТОВЫЙ контур") {
t.Fatalf("метка тестового сервера: %q %q", tag, line)
}
if tag, _ := alertServer("prod-gost"); strings.Contains(tag, "ТЕСТ") {
t.Fatalf("метка боевого сервера: %q", tag)
}
}
// История FSM — по МСК (время хранится в UTC), текущее состояние подписано.
func TestClaimStagesMSK(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
in := time.Date(2026, 10, 23, 12, 17, 19, 502000000, time.UTC)
left := in.Add(time.Minute)
rec := httptest.NewRecorder()
render(rec, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: ClaimView{ID: "0123456789abcdef", Status: "confirmed",
Stages: []StageView{{State: "awaiting_decision", EnteredAt: in, LeftAt: &left}, {State: "confirmed", EnteredAt: left}}}})
body := rec.Body.String()
for _, want := range []string{"23.10 15:17:19.502", "23.10 15:18:19.502", "текущее состояние"} {
if !strings.Contains(body, want) {
t.Errorf("в истории нет %q", want)
}
}
if strings.Contains(body, "12:17:19") {
t.Error("время в UTC")
}
}
+8
View File
@@ -270,6 +270,8 @@ var lkPortfolioState = struct {
// (23.10.2026 при включении «для всех» 481 договор ушёл дважды). // (23.10.2026 при включении «для всех» 481 договор ушёл дважды).
var lkPortfolioRun sync.Mutex var lkPortfolioRun sync.Mutex
var lkPortfolioTestOnce sync.Once
// lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force — кроме // lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force — кроме
// отправленных в последнюю минуту). Одно соединение с DEPO на проход; «для всех клиентов» — остатки одним // отправленных в последнюю минуту). Одно соединение с DEPO на проход; «для всех клиентов» — остатки одним
// запросом (23.10.2026: по два новых соединения и запросу на каждого из ~600 клиентов — ~50 входов в DEPO в секунду). // запросом (23.10.2026: по два новых соединения и запросу на каждого из ~600 клиентов — ~50 входов в DEPO в секунду).
@@ -278,6 +280,12 @@ func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) {
if !st.Enabled { if !st.Enabled {
return return
} }
if lkProdOnTestNSD(rc) { // тестовая DEPO не пишет портфели клиентов в боевой ЛК (lkconnector.go)
lkPortfolioTestOnce.Do(func() {
log.Printf("lk-portfolio: не заполняется — боевой ЛК на сервере с тестовым контуром НРД (%s)", rc.Snapshot().NSD.Profile)
})
return
}
lkPortfolioRun.Lock() lkPortfolioRun.Lock()
defer lkPortfolioRun.Unlock() defer lkPortfolioRun.Unlock()
fs := rc.Snapshot().Fancy fs := rc.Snapshot().Fancy
+2 -1
View File
@@ -287,7 +287,8 @@ func lkFinalizeByGUID(ctx context.Context, rc *RuntimeConfig, guid, status, verd
cctx, cancel := context.WithTimeout(ctx, 12*time.Second) cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel() defer cancel()
var id int64 var id int64
if err := db.QueryRowContext(cctx, "SELECT id FROM lk.transfer_requests WHERE guid=$1", guid).Scan(&id); err != nil { // GUID без учёта регистра: НРД отвечает заглавными, мы шлём строчными
if err := db.QueryRowContext(cctx, "SELECT id FROM lk.transfer_requests WHERE lower(guid::text)=lower($1)", guid).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil return nil
} }
+43
View File
@@ -18,6 +18,7 @@ import (
"sync" "sync"
"time" "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/m2mcore"
) )
@@ -421,6 +422,9 @@ func orderWatchTick(ctx context.Context, rc *RuntimeConfig, svc *Service) {
continue continue
} }
orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, outgoingLineRefs(ctx, svc, od), "Поручение 16 (списание)") orderWatchCheck(ctx, rc, od.GUID, "out", od.OrderAt, outgoingLineRefs(ctx, svc, od), "Поручение 16 (списание)")
if w := globalOrderWatch.Get(od.GUID); w != nil && !w.MS010At.IsZero() && time.Since(w.MS010At) < 7*24*time.Hour {
outOnExecuted(ctx, rc, svc, od.GUID, w)
}
} }
} }
if globalIncomingStore != nil { if globalIncomingStore != nil {
@@ -433,6 +437,45 @@ func orderWatchTick(ctx context.Context, rc *RuntimeConfig, svc *Service) {
} }
} }
// outOnExecuted — НРД закрыл MS010 все строки нашего поручения 16: сделка → «Исполнена» (если списана
// хоть одна бумага), итог — в ЛК ЕСИА Финанс. Вызывается каждый проход, повтор ничего не меняет
// (23.10.2026: после MS010 сделка 97EAD00C оставалась «Подтверждена», в ЛК статус не менялся).
// Тест «боевой ЛК → TEST3» — свой итог (realTestOnExecuted).
func outOnExecuted(ctx context.Context, rc *RuntimeConfig, svc *Service, guid string, w *OrderWatch) {
total, executed, rejected, _ := w.linesSummary()
if total == 0 && !w.Rejected { // старые записи без строк — одна строка
total, executed = 1, 1
}
if svc != nil && executed > 0 {
d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid))
if err != nil {
d, err = svc.Repo().GetByGUID(ctx, m2m.UUID(strings.ToLower(guid)))
}
if err == nil && (d.State == m2mcore.StateConfirmed || d.State == m2mcore.StateAwaitingSUB16) {
if err := d.CompleteSUB16(ctx); err != nil {
log.Printf("order-watch: %s: перевод не отмечен исполненным: %v", shortGUID(guid), err)
} else if err := svc.Repo().Update(ctx, d); err != nil {
log.Printf("order-watch: %s: сохранение «Исполнена»: %v", shortGUID(guid), err)
} else {
log.Printf("order-watch: %s: поручение 16 исполнено (MS010) — перевод «Исполнена»", shortGUID(guid))
}
}
}
if realTestGet(guid) != nil || !lkLinked(guid) {
return
}
status, reason := "done", "Перевод исполнен: бумаги списаны"
switch {
case executed == 0:
status, reason = "rejected", "НРД не исполнил поручение на списание"
case rejected > 0:
reason = fmt.Sprintf("Перевод исполнен частично: исполнено %d из %d бумаг", executed, total)
}
globalLKFlows.stepIfExists(guid, pick3(status == "done", "response", "reject"), "Итог НРД (MS010) → статус в ЛК", reason)
OnLKOutcome(ctx, rc, guid, status, reason)
log.Printf("order-watch: %s: итог в ЛК — %s (%s)", shortGUID(guid), status, reason)
}
// incomingOrderLines — строки поданных 16/1. Старые записи (до строчных событий) знали // incomingOrderLines — строки поданных 16/1. Старые записи (до строчных событий) знали
// только первый contr_d_id — для них одна строка без REFERENCE. // только первый contr_d_id — для них одна строка без REFERENCE.
func incomingOrderLines(it *IncomingTransfer) []orderLine { func incomingOrderLines(it *IncomingTransfer) []orderLine {
@@ -282,13 +282,13 @@
<div class="card"> <div class="card">
<h2>История FSM</h2> <h2>История FSM</h2>
<table> <table>
<thead><tr><th>Состояние</th><th>Вошли</th><th>Вышли</th><th>Причина</th></tr></thead> <thead><tr><th>Состояние</th><th>Вошли (МСК)</th><th>Вышли (МСК)</th><th>Причина</th></tr></thead>
<tbody> <tbody>
{{range .Claim.Stages}} {{range .Claim.Stages}}
<tr> <tr>
<td><span class="badge {{.State}}">{{ruState .State}}</span></td> <td><span class="badge {{.State}}">{{ruState .State}}</span></td>
<td>{{.EnteredAt.Format "15:04:05.000"}}</td> <td>{{mskMs .EnteredAt}}</td>
<td>{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}<span class="muted">сейчас</span>{{end}}</td> <td>{{with .LeftAt}}{{mskMs .}}{{else}}<span class="muted">текущее состояние</span>{{end}}</td>
<td class="muted">{{.Reason}}</td> <td class="muted">{{.Reason}}</td>
</tr> </tr>
{{end}} {{end}}
@@ -675,6 +675,7 @@
{{if devOn}} <input type="hidden" name="emulate_nsd_form" value="1"> {{if devOn}} <input type="hidden" name="emulate_nsd_form" value="1">
<label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;flex-basis:100%;margin-top:6px"><input type="checkbox" name="emulate_nsd" style="width:auto;margin-top:3px" {{if .Settings.LK.EmulateNSD}}checked{{end}} onchange="this.form.submit()"> <span><b>Имитация НРД</b> — заявки ЛК проходят сверку с DEPO, блокировку в QUIK и получают статус в ЛК, но в НРД не отправляются: ответ контрагента имитирует bj.{{if and (eq .Settings.LK.ActiveEnv "prod") (not .Settings.LK.EmulateNSD)}} <span style="color:var(--warn)">Сейчас включена принудительно: боевой ЛК при тестовом профиле ИШ ({{.Settings.NSD.Profile}}).</span>{{end}}</span></label>{{end}} <label style="display:flex;align-items:flex-start;gap:8px;font-weight:400;flex-basis:100%;margin-top:6px"><input type="checkbox" name="emulate_nsd" style="width:auto;margin-top:3px" {{if .Settings.LK.EmulateNSD}}checked{{end}} onchange="this.form.submit()"> <span><b>Имитация НРД</b> — заявки ЛК проходят сверку с DEPO, блокировку в QUIK и получают статус в ЛК, но в НРД не отправляются: ответ контрагента имитирует bj.{{if and (eq .Settings.LK.ActiveEnv "prod") (not .Settings.LK.EmulateNSD)}} <span style="color:var(--warn)">Сейчас включена принудительно: боевой ЛК при тестовом профиле ИШ ({{.Settings.NSD.Profile}}).</span>{{end}}</span></label>{{end}}
</form> </form>
{{if lkProdOnTestNSD}}<p style="margin:10px 0 0;padding:9px 12px;border-radius:8px;background:var(--warn-weak);font-size:13px"><b>Боевой ЛК на сервере с тестовым контуром НРД ({{.Settings.NSD.Profile}}).</b> Заявки клиентов здесь не берутся — их обрабатывает боевой сервер bj; берутся только заявки теста «боевой ЛК → тестовый НРД». Портфели ДЕПО в ЛК отсюда не заполняются.</p>{{end}}
<div id="lk-portfolio" style="margin-top:14px;padding:12px 14px;border:1px solid var(--border);border-radius:10px"> <div id="lk-portfolio" style="margin-top:14px;padding:12px 14px;border:1px solid var(--border);border-radius:10px">
<b>Заполнение портфеля ДЕПО бумагами из DEPO</b> {{if .Settings.LK.PortfolioSync.Enabled}}<span class="badge confirmed">включено{{if .Settings.LK.PortfolioSync.AllClients}} — все клиенты{{else}} — по списку{{end}}</span>{{else}}<span class="badge draft">выключено</span>{{end}} <b>Заполнение портфеля ДЕПО бумагами из DEPO</b> {{if .Settings.LK.PortfolioSync.Enabled}}<span class="badge confirmed">включено{{if .Settings.LK.PortfolioSync.AllClients}} — все клиенты{{else}} — по списку{{end}}</span>{{else}}<span class="badge draft">выключено</span>{{end}}
<p class="muted" style="margin:6px 0 8px;font-size:13px">Для клиентов из списка — каждые 10 секунд, для всех клиентов — раз в 5 минут (одним запросом к DEPO) bj сверяет портфель клиента в DEPO и отправляет его в ЛК (<code>/portfolio/import</code>, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).</p> <p class="muted" style="margin:6px 0 8px;font-size:13px">Для клиентов из списка — каждые 10 секунд, для всех клиентов — раз в 5 минут (одним запросом к DEPO) bj сверяет портфель клиента в DEPO и отправляет его в ЛК (<code>/portfolio/import</code>, портфель ДЕПО) — только если остатки изменились. Номер гос. регистрации — из экспорта QUIK, раздел счёта — «Основной», хранение — НРД. Бумаги без номера гос. регистрации не отправляются (ЛК отвергает весь импорт).</p>
@@ -755,7 +756,8 @@
<details style="margin-top:6px"><summary style="cursor:pointer">Все договоры ({{.Total}})</summary>{{range .All}}<div class="muted" style="margin-top:4px">{{template "lkpfRow" .}}</div>{{end}}</details>{{end}} <details style="margin-top:6px"><summary style="cursor:pointer">Все договоры ({{.Total}})</summary>{{range .All}}<div class="muted" style="margin-top:4px">{{template "lkpfRow" .}}</div>{{end}}</details>{{end}}
</div>{{end}}{{end}} </div>{{end}}{{end}}
</div> </div>
<div id="lk-real-nsd" style="margin-top:14px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;{{if .Settings.LK.RealNSDTest.Enabled}}background:var(--warn-weak){{end}}"> {{/* тестовый режим — только в режиме разработки; включённый виден всегда, чтобы его можно было выключить */}}
{{if or devOn .Settings.LK.RealNSDTest.Enabled}}<div id="lk-real-nsd" style="margin-top:14px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;{{if .Settings.LK.RealNSDTest.Enabled}}background:var(--warn-weak){{end}}">
<b>Тест «боевой ЛК → тестовый контур НРД»</b> {{if .Settings.LK.RealNSDTest.Enabled}}<span class="badge awaiting_decision">включён</span>{{else}}<span class="badge draft">выключен</span>{{end}} <b>Тест «боевой ЛК → тестовый контур НРД»</b> {{if .Settings.LK.RealNSDTest.Enabled}}<span class="badge awaiting_decision">включён</span>{{else}}<span class="badge draft">выключен</span>{{end}}
<p class="muted" style="margin:6px 0 8px;font-size:13px">Заявки клиентов из списка проходят у нас всё по-настоящему: сверка и поручение в DEPO, блокировка и списание в QUIK МНП (<b>боевая запись</b>), статусы в ЛК. В НРД (тестовый контур) запрос уходит с данными тест-инвестора <b>{{realNSDAs}}</b>; GUID, бумаги и счета — настоящие, ответы НРД применяются к заявке клиента. «Исполнена» в ЛК — по отчёту НРД об исполнении (MS010). Остальные заявки ЛК — как раньше. Включайте до того, как клиент подпишет заявку.</p> <p class="muted" style="margin:6px 0 8px;font-size:13px">Заявки клиентов из списка проходят у нас всё по-настоящему: сверка и поручение в DEPO, блокировка и списание в QUIK МНП (<b>боевая запись</b>), статусы в ЛК. В НРД (тестовый контур) запрос уходит с данными тест-инвестора <b>{{realNSDAs}}</b>; GUID, бумаги и счета — настоящие, ответы НРД применяются к заявке клиента. «Исполнена» в ЛК — по отчёту НРД об исполнении (MS010). Остальные заявки ЛК — как раньше. Включайте до того, как клиент подпишет заявку.</p>
<form method="post" action="/admin/setup/lk-real-nsd" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap" onsubmit="return !this.enabled.checked || confirm('Включить? Для клиентов из списка QUIK МНП будет записывать по-настоящему (блокировка и списание бумаг).');"> <form method="post" action="/admin/setup/lk-real-nsd" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap" onsubmit="return !this.enabled.checked || confirm('Включить? Для клиентов из списка QUIK МНП будет записывать по-настоящему (блокировка и списание бумаг).');">
@@ -764,7 +766,7 @@
<input name="docs" value="{{.Settings.LK.RealNSDTest.Docs}}" placeholder="1110 754503"></label> <input name="docs" value="{{.Settings.LK.RealNSDTest.Docs}}" placeholder="1110 754503"></label>
<button class="btn" type="submit">Сохранить</button> <button class="btn" type="submit">Сохранить</button>
</form> </form>
</div> </div>{{end}}
</div> </div>
{{if devOn}} <div class="card"> {{if devOn}} <div class="card">