ЛК: итог «Исполнена» по 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:
@@ -67,6 +67,7 @@ var templateFuncs = template.FuncMap{
|
||||
"quikRecipientsInfo": quikRecipientsInfo,
|
||||
"quikNeedsTrust": quikNeedsTrust,
|
||||
"lkPortfolioSummary": lkPortfolioSummary,
|
||||
"lkProdOnTestNSD": func() bool { return lkProdOnTestNSD(globalRC) },
|
||||
"backupStatus": backupStatus,
|
||||
"backupWhen": backupWhen,
|
||||
"smbWin": smbWin,
|
||||
@@ -247,6 +248,13 @@ var templateFuncs = template.FuncMap{
|
||||
}
|
||||
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": func(a, b time.Time) string {
|
||||
if a.IsZero() || b.IsZero() {
|
||||
|
||||
@@ -32,7 +32,8 @@ func dispatchAlert(rc *RuntimeConfig, title, body string) {
|
||||
s := a.SMTP
|
||||
s.To = notifyRecipients(cfg, alertKind(title))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
// sendSMTP отправляет письмо согласно настройкам (STARTTLS/SSL/без шифрования,
|
||||
|
||||
@@ -33,6 +33,10 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) {
|
||||
}
|
||||
subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт"
|
||||
body := buildIncomingEmailBody(it)
|
||||
if !nsdProfileProd(cfg.NSD.Profile) { // письмо тестового сервера не принять за боевое
|
||||
tag, line := alertServer(cfg.NSD.Profile)
|
||||
subject, body = tag+subject, line+body
|
||||
}
|
||||
s := smtp
|
||||
s.To = notifyRecipients(cfg, "incoming") // получатели по маршруту «входящие переводы»
|
||||
if err := sendSMTP(s, subject, body); err != nil {
|
||||
@@ -95,7 +99,12 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed
|
||||
|
||||
m := smtp
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func lkClaimInvestorKey(cl map[string]any) string {
|
||||
|
||||
// lkClaimLinks — guid отправленного в НРД запроса → id заявки в ЛК, чтобы по
|
||||
// ответу НРД PATCH-нуть статус на их стороне. Переживает рестарт.
|
||||
const lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json"
|
||||
var lkLinksFile = "/var/lib/bj/.bj/lk-claim-links.json" // var — тесты подменяют путь
|
||||
|
||||
var (
|
||||
lkLinksMu sync.Mutex
|
||||
@@ -47,10 +47,24 @@ var (
|
||||
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) {
|
||||
lkLinksMu.Lock()
|
||||
lkEnsureLinksLoaded()
|
||||
lkLinks[guid] = claimID
|
||||
lkLinks[lkGUIDKey(guid)] = claimID
|
||||
if raw, err := json.Marshal(lkLinks); err == nil {
|
||||
_ = os.WriteFile(lkLinksFile, raw, 0o600)
|
||||
}
|
||||
@@ -67,11 +81,12 @@ func lkEnsureLinksLoaded() {
|
||||
if raw, err := os.ReadFile(lkLinksFile); err == nil {
|
||||
_ = json.Unmarshal(raw, &lkLinks)
|
||||
}
|
||||
lkNormKeys(lkLinks)
|
||||
}
|
||||
|
||||
// lkOrigin — guid → id заявки ЛК, ПОСТОЯННАЯ запись происхождения (для UI
|
||||
// «путь заявки»). lkLinks чистится после финального PATCH, а origin остаётся.
|
||||
const lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
|
||||
var lkOriginFile = "/var/lib/bj/.bj/lk-origin.json"
|
||||
|
||||
var (
|
||||
lkOriginMu sync.Mutex
|
||||
@@ -84,6 +99,7 @@ func lkEnsureOriginLoaded() {
|
||||
return
|
||||
}
|
||||
lkOriginLoaded = true
|
||||
defer lkNormKeys(lkOrigin)
|
||||
if raw, err := os.ReadFile(lkOriginFile); err == nil {
|
||||
_ = json.Unmarshal(raw, &lkOrigin)
|
||||
return
|
||||
@@ -98,7 +114,7 @@ func lkRecordOrigin(guid string, claimID int) {
|
||||
lkOriginMu.Lock()
|
||||
defer lkOriginMu.Unlock()
|
||||
lkEnsureOriginLoaded()
|
||||
lkOrigin[guid] = claimID
|
||||
lkOrigin[lkGUIDKey(guid)] = claimID
|
||||
if raw, err := json.Marshal(lkOrigin); err == nil {
|
||||
_ = os.WriteFile(lkOriginFile, raw, 0o600)
|
||||
}
|
||||
@@ -110,10 +126,19 @@ func LKClaimIDByGUID(guid string) (int, bool) {
|
||||
lkOriginMu.Lock()
|
||||
defer lkOriginMu.Unlock()
|
||||
lkEnsureOriginLoaded()
|
||||
id, ok := lkOrigin[guid]
|
||||
id, ok := lkOrigin[lkGUIDKey(guid)]
|
||||
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 защита от дублей
|
||||
// переводов при повторных проходах поллера). Переживает рестарт.
|
||||
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) {
|
||||
lkLinksMu.Lock()
|
||||
lkEnsureLinksLoaded()
|
||||
id, ok := lkLinks[guid]
|
||||
id, ok := lkLinks[lkGUIDKey(guid)]
|
||||
if ok {
|
||||
delete(lkLinks, guid)
|
||||
delete(lkLinks, lkGUIDKey(guid))
|
||||
if raw, err := json.Marshal(lkLinks); err == nil {
|
||||
_ = os.WriteFile(lkLinksFile, raw, 0o600)
|
||||
}
|
||||
@@ -345,7 +370,7 @@ func PullLKClaims(ctx context.Context, rc *RuntimeConfig, svc *Service) (LKPullR
|
||||
continue // чужой вид/статус — не наша заявка
|
||||
}
|
||||
id := toInt(c["id"])
|
||||
if id == 0 || lkAlreadySent(id) {
|
||||
if id == 0 || lkAlreadySent(id) || lkTestSkipped(id) {
|
||||
continue
|
||||
}
|
||||
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))
|
||||
continue
|
||||
}
|
||||
if outcome == "skipped" {
|
||||
res.Fetched--
|
||||
continue
|
||||
}
|
||||
if outcome == "rejected" {
|
||||
res.Rejected++
|
||||
} else {
|
||||
@@ -385,6 +414,13 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
|
||||
if err != nil {
|
||||
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 {
|
||||
nsd := rc.Snapshot().NSD
|
||||
@@ -427,8 +463,16 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
|
||||
return "", fmt.Errorf("заявка #%d уже отправлялась в НРД — повторная отправка подавлена", id)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Успешно ушло в НРД — фиксируем, чтобы повторный проход не создал дубль.
|
||||
@@ -443,6 +487,36 @@ func pullOneLKClaim(ctx context.Context, rc *RuntimeConfig, svc *Service, ep LKE
|
||||
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.
|
||||
func lkClaimToRequest(ctx context.Context, rc *RuntimeConfig, ep LKEndpoint, cl, data map[string]any) (CreateClaimRequest, error) {
|
||||
var req CreateClaimRequest
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
_ = note // клиенту в ЛК — только короткий итог, подробности в журнале bj
|
||||
if realTestGet(string(decision.Header.GUID)) != nil && conf > 0 {
|
||||
// тест «боевой ЛК → TEST3»: согласие контрагента — ещё не исполнение; «Исполнена» — по MS010 (realtest.go)
|
||||
if (realTestGet(string(decision.Header.GUID)) != nil || source == "поллер") && conf > 0 {
|
||||
// согласие контрагента — ещё не исполнение: «Исполнена» в ЛК — по MS010 на наше поручение 16
|
||||
// (outOnExecuted, тест «боевой ЛК → TEST3» — realtest.go); имитатор НРД MS010 не шлёт — итог сразу
|
||||
globalLKFlows.stepIfExists(string(decision.Header.GUID), "response", "Контрагент согласился — ждём исполнения НРД",
|
||||
fmt.Sprintf("Принято %d, отклонено %d. Статус в ЛК — «В работе» до отчёта НРД об исполнении (MS010).", conf, rej))
|
||||
} else {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,8 @@ var lkPortfolioState = struct {
|
||||
// (23.10.2026 при включении «для всех» 481 договор ушёл дважды).
|
||||
var lkPortfolioRun sync.Mutex
|
||||
|
||||
var lkPortfolioTestOnce sync.Once
|
||||
|
||||
// lkPortfolioSyncTick — сверка портфелей с DEPO; в ЛК уходит только изменившийся (или при force — кроме
|
||||
// отправленных в последнюю минуту). Одно соединение с DEPO на проход; «для всех клиентов» — остатки одним
|
||||
// запросом (23.10.2026: по два новых соединения и запросу на каждого из ~600 клиентов — ~50 входов в DEPO в секунду).
|
||||
@@ -278,6 +280,12 @@ func lkPortfolioSyncTick(ctx context.Context, rc *RuntimeConfig, force bool) {
|
||||
if !st.Enabled {
|
||||
return
|
||||
}
|
||||
if lkProdOnTestNSD(rc) { // тестовая DEPO не пишет портфели клиентов в боевой ЛК (lkconnector.go)
|
||||
lkPortfolioTestOnce.Do(func() {
|
||||
log.Printf("lk-portfolio: не заполняется — боевой ЛК на сервере с тестовым контуром НРД (%s)", rc.Snapshot().NSD.Profile)
|
||||
})
|
||||
return
|
||||
}
|
||||
lkPortfolioRun.Lock()
|
||||
defer lkPortfolioRun.Unlock()
|
||||
fs := rc.Snapshot().Fancy
|
||||
|
||||
@@ -287,7 +287,8 @@ func lkFinalizeByGUID(ctx context.Context, rc *RuntimeConfig, guid, status, verd
|
||||
cctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
@@ -421,6 +422,9 @@ func orderWatchTick(ctx context.Context, rc *RuntimeConfig, svc *Service) {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
@@ -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. Старые записи (до строчных событий) знали
|
||||
// только первый contr_d_id — для них одна строка без REFERENCE.
|
||||
func incomingOrderLines(it *IncomingTransfer) []orderLine {
|
||||
|
||||
@@ -282,13 +282,13 @@
|
||||
<div class="card">
|
||||
<h2>История FSM</h2>
|
||||
<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>
|
||||
{{range .Claim.Stages}}
|
||||
<tr>
|
||||
<td><span class="badge {{.State}}">{{ruState .State}}</span></td>
|
||||
<td>{{.EnteredAt.Format "15:04:05.000"}}</td>
|
||||
<td>{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}<span class="muted">сейчас</span>{{end}}</td>
|
||||
<td>{{mskMs .EnteredAt}}</td>
|
||||
<td>{{with .LeftAt}}{{mskMs .}}{{else}}<span class="muted">текущее состояние</span>{{end}}</td>
|
||||
<td class="muted">{{.Reason}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -675,6 +675,7 @@
|
||||
{{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}}
|
||||
</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">
|
||||
<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>
|
||||
@@ -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}}
|
||||
</div>{{end}}{{end}}
|
||||
</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}}
|
||||
<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 МНП будет записывать по-настоящему (блокировка и списание бумаг).');">
|
||||
@@ -764,7 +766,7 @@
|
||||
<input name="docs" value="{{.Settings.LK.RealNSDTest.Docs}}" placeholder="1110 754503"></label>
|
||||
<button class="btn" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
|
||||
{{if devOn}} <div class="card">
|
||||
|
||||
Reference in New Issue
Block a user