diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 1458924..90fc3a8 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -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() { diff --git a/internal/lkgateway/alerts.go b/internal/lkgateway/alerts.go index e7a6461..6757438 100644 --- a/internal/lkgateway/alerts.go +++ b/internal/lkgateway/alerts.go @@ -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/без шифрования, diff --git a/internal/lkgateway/incoming_notify.go b/internal/lkgateway/incoming_notify.go index 5843a90..154b3ac 100644 --- a/internal/lkgateway/incoming_notify.go +++ b/internal/lkgateway/incoming_notify.go @@ -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 } diff --git a/internal/lkgateway/lkconnector.go b/internal/lkgateway/lkconnector.go index 89f4fa6..810b7d8 100644 --- a/internal/lkgateway/lkconnector.go +++ b/internal/lkgateway/lkconnector.go @@ -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 diff --git a/internal/lkgateway/lkemulate.go b/internal/lkgateway/lkemulate.go index 23b7e8f..0f3ceed 100644 --- a/internal/lkgateway/lkemulate.go +++ b/internal/lkgateway/lkemulate.go @@ -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 { diff --git a/internal/lkgateway/lkoutcome_test.go b/internal/lkgateway/lkoutcome_test.go new file mode 100644 index 0000000..521a5eb --- /dev/null +++ b/internal/lkgateway/lkoutcome_test.go @@ -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") + } +} diff --git a/internal/lkgateway/lkportfolio.go b/internal/lkgateway/lkportfolio.go index d64af99..6881bbd 100644 --- a/internal/lkgateway/lkportfolio.go +++ b/internal/lkgateway/lkportfolio.go @@ -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 diff --git a/internal/lkgateway/lkstore.go b/internal/lkgateway/lkstore.go index ff4f9b6..975fc92 100644 --- a/internal/lkgateway/lkstore.go +++ b/internal/lkgateway/lkstore.go @@ -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 } diff --git a/internal/lkgateway/orderwatch.go b/internal/lkgateway/orderwatch.go index fd00a72..cfc7693 100644 --- a/internal/lkgateway/orderwatch.go +++ b/internal/lkgateway/orderwatch.go @@ -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 { diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html index 3138ac9..27ce7ce 100644 --- a/internal/lkgateway/web/templates/admin_claim.html +++ b/internal/lkgateway/web/templates/admin_claim.html @@ -282,13 +282,13 @@
| Состояние | Вошли | Вышли | Причина | ||
|---|---|---|---|---|---|
| Состояние | Вошли (МСК) | Вышли (МСК) | Причина | ||
| {{ruState .State}} | -{{.EnteredAt.Format "15:04:05.000"}} | -{{if .LeftAt}}{{.LeftAt.Format "15:04:05.000"}}{{else}}сейчас{{end}} | +{{mskMs .EnteredAt}} | +{{with .LeftAt}}{{mskMs .}}{{else}}текущее состояние{{end}} | {{.Reason}} |