diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 52979b3..3e957b0 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -47,24 +47,25 @@ var templateFuncs = template.FuncMap{ return template.URL(defaultLogoDataURI(uri)) }, // m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)». - "m2mCode": m2mCodeLabel, - "quikCodeMap": quikClientCodeMapText, - "curUser": authTemplateFuncs["curUser"], - "can": authTemplateFuncs["can"], - "sendFailText": sendFailText, - "realTest": realTestGet, - "realNSDAs": realNSDAsText, - "nsdSegTip": nsdSegTip, - "devOn": authTemplateFuncs["devOn"], - "devLicensed": authTemplateFuncs["devLicensed"], - "mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии - "isIIS": incomingIsIIS, // входящий перевод на ИИС - "depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера - "authOpen": authOpen, - "internalDocs": func() bool { return internalDocsBuilt }, - "roleTitle": roleTitle, - "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, - "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, + "m2mCode": m2mCodeLabel, + "quikCodeMap": quikClientCodeMapText, + "curUser": authTemplateFuncs["curUser"], + "can": authTemplateFuncs["can"], + "sendFailText": sendFailText, + "realTest": realTestGet, + "realNSDAs": realNSDAsText, + "quikRecipientsInfo": quikRecipientsInfo, + "nsdSegTip": nsdSegTip, + "devOn": authTemplateFuncs["devOn"], + "devLicensed": authTemplateFuncs["devLicensed"], + "mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии + "isIIS": incomingIsIIS, // входящий перевод на ИИС + "depoSubs": func() any { return depoWizardSubs }, // экраны шага 3 мастера + "authOpen": authOpen, + "internalDocs": func() bool { return internalDocsBuilt }, + "roleTitle": roleTitle, + "stageCls": func(r depoQueueRow) string { c, _ := r.StageRu(); return c }, + "stageText": func(r depoQueueRow) string { _, t := r.StageRu(); return t }, "deref": func(v *float64) float64 { if v == nil { return 0 diff --git a/internal/lkgateway/bub_live_test.go b/internal/lkgateway/bub_live_test.go new file mode 100644 index 0000000..ab5ca4b --- /dev/null +++ b/internal/lkgateway/bub_live_test.go @@ -0,0 +1,37 @@ +//go:build live + +package lkgateway + +import ( + "context" + "fmt" + "testing" +) + +// Только чтение: остатки SBER по счёту клиента в DEPO во всех разрезах. ACC=ВТНКЦ00002000 +func TestDepoRestsLive(t *testing.T) { + rc, err := NewRuntimeConfig("/var/lib/bj/.bj/setup.json") + if err != nil { + t.Fatal(err) + } + fs := rc.Snapshot().Fancy + db, err := fancyOpen(fs, fs.DepoDB()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + rows, err := db.QueryContext(context.Background(), `SELECT c.NUMBER, ISNULL(p.PORT_NUM,''), v.ISIN, CAST(rst.K AS float), CAST(ISNULL(rst.A,0) AS float), ISNULL(rst.BLOCK_REASON,''), ISNULL(rst.BAL_ACC,0) + FROM OD_RESTS rst JOIN OD_CLIENTS c ON c.ID=rst.REG_1 LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID + JOIN OD_VALUES v ON v.ID=rst.VALUE_ID + WHERE c.NUMBER IN ('ВТНКЦ00002000','ВТНКЦ00000816') AND v.ISIN='RU0009029540'`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var num, port, isin, br string + var k, a float64 + var bal int + _ = rows.Scan(&num, &port, &isin, &k, &a, &br, &bal) + fmt.Printf("%s %s %s K=%v A=%v блок=%q бал.счёт=%d\n", num, port, isin, k, a, br, bal) + } +} diff --git a/internal/lkgateway/nsdpoller.go b/internal/lkgateway/nsdpoller.go index 527ddc1..496e840 100644 --- a/internal/lkgateway/nsdpoller.go +++ b/internal/lkgateway/nsdpoller.go @@ -263,6 +263,7 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error { log.Printf("depo-events: справочник после получения: %v", err) } }() + quikRecipientsSyncAsync(s.rc) // депозитарии-получатели в МНП (quikrecipients.go) log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d (состав изменился)", p.ID, len(hb.Participants.Participants), len(hb.SettlementPlaces.Places)) return nil diff --git a/internal/lkgateway/quikrecipients.go b/internal/lkgateway/quikrecipients.go new file mode 100644 index 0000000..ee1801d --- /dev/null +++ b/internal/lkgateway/quikrecipients.go @@ -0,0 +1,187 @@ +package lkgateway + +// Справочник «Депозитарии получателей для переводов M2M» в БД МНП (M2MDepoRecipients) ведёт bj — по +// справочнику участников M2M от НРД (решение 29.09.2026). QUIK этот справочник сам не получает, а в +// поручении «Вывод ЦБ (M2M)» депозитарий получателя обязателен и сверяется с ним: без записи МНП +// отвергает регистрацию («Неизвестный депозитарий получателя»), и бумаги клиента не блокируются. +// Модуль МНП перечитывает справочник при запуске (ночной перезапуск), поэтому справочник обновляется +// в течение дня до 20:00 — к ночи в базе МНП полный актуальный список: раз в 30 минут и сразу при +// изменении справочника НРД. Добавляются недостающие депозитарии и исправляются наименования; +// записи, которых нет в справочнике НРД, не удаляются. + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "strings" + "sync" + "time" +) + +const ( + quikRecipientsEvery = 30 * time.Minute + quikRecipientsLastHour = 20 // после 20:00 не обновляем: модуль уже не перечитает до ночного перезапуска +) + +var quikRecipientsState struct { + sync.Mutex + At time.Time + Total int + Added int + Renamed int + Err string + running bool +} + +// quikRecipientsSync — привести M2MDepoRecipients в соответствие справочнику НРД. +func quikRecipientsSync(ctx context.Context, rc *RuntimeConfig) (total, added, renamed int, err error) { + s := rc.Snapshot().Quik + if strings.TrimSpace(s.Server) == "" { + return 0, 0, 0, nil + } + raw, _ := readHandbook() + var hb struct { + Participants []HandbookEntry `json:"participants"` + } + if err := json.Unmarshal(raw, &hb); err != nil { + return 0, 0, 0, fmt.Errorf("справочник НРД: %w", err) + } + want := map[string]string{} + for _, p := range hb.Participants { + inn := strings.TrimSpace(p.INN) + if inn == "" { + continue + } + name := strings.TrimSpace(p.Name) + if name == "" { + name = strings.TrimSpace(p.DisplayName) + } + if name == "" { + name = strings.TrimSpace(p.FullName) + } + if name == "" { + name = "Депозитарий ИНН " + inn + } + if r := []rune(name); len(r) > 128 { + name = string(r[:128]) + } + if _, ok := want[inn]; !ok { + want[inn] = name + } + } + if len(want) == 0 { + return 0, 0, 0, fmt.Errorf("справочник НРД пуст") + } + conn, err := quikOpen(s) + if err != nil { + return 0, 0, 0, err + } + defer conn.Close() + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + have := map[string]string{} + rows, err := conn.QueryContext(cctx, "SELECT INN, ISNULL(Name,'') FROM M2MDepoRecipients") + if err != nil { + return 0, 0, 0, fmt.Errorf("чтение M2MDepoRecipients: %s", quikErrorDetail(err)) + } + for rows.Next() { + var inn, name string + if rows.Scan(&inn, &name) == nil { + have[strings.TrimSpace(inn)] = strings.TrimSpace(name) + } + } + rows.Close() + for inn, name := range want { + cur, ok := have[inn] + if ok && cur == name { + continue + } + if _, err := conn.ExecContext(cctx, "exec dbo.imp_M2MDepoRecipients @Operation=1, @INN=@inn, @Name=@nm", sql.Named("inn", inn), sql.Named("nm", name)); err != nil { + return len(want), added, renamed, fmt.Errorf("imp_M2MDepoRecipients %s: %s", inn, quikErrorDetail(err)) + } + if ok { + renamed++ + } else { + added++ + } + } + return len(want), added, renamed, nil +} + +// quikRecipientsRun — один проход с учётом окна (до 20:00) и записью состояния. +func quikRecipientsRun(ctx context.Context, rc *RuntimeConfig, why string) { + if rc == nil || !moduleAllowed(rc, "quik-mnp") || time.Now().Hour() >= quikRecipientsLastHour { + return + } + quikRecipientsState.Lock() + if quikRecipientsState.running { + quikRecipientsState.Unlock() + return + } + quikRecipientsState.running = true + quikRecipientsState.Unlock() + total, added, renamed, err := quikRecipientsSync(ctx, rc) + quikRecipientsState.Lock() + quikRecipientsState.running = false + quikRecipientsState.At, quikRecipientsState.Total, quikRecipientsState.Added, quikRecipientsState.Renamed = time.Now(), total, added, renamed + prevErr := quikRecipientsState.Err + quikRecipientsState.Err = "" + if err != nil { + quikRecipientsState.Err = err.Error() + } + quikRecipientsState.Unlock() + switch { + case err != nil: + log.Printf("quik: справочник получателей M2M (%s): %v", why, err) + if prevErr == "" { + raiseSystemAlert(rc, "QUIK МНП: справочник депозитариев-получателей не обновлён", + "bj не смог обновить справочник «Депозитарии получателей для переводов M2M» в базе МНП: "+err.Error()+". Без записи о депозитарии получателя МНП отвергает поручения на вывод, и перевод не уйдёт в НРД.") + } + case added > 0 || renamed > 0: + log.Printf("quik: справочник получателей M2M обновлён (%s): добавлено %d, наименований исправлено %d, всего по справочнику НРД %d — модуль МНП применит после перезапуска", why, added, renamed, total) + } +} + +// quikRecipientsSyncAsync — справочник НРД изменился: обновить и в МНП. +func quikRecipientsSyncAsync(rc *RuntimeConfig) { + go quikRecipientsRun(context.Background(), rc, "изменился справочник НРД") +} + +// quikRecipientsLoop — раз в 30 минут до 20:00. +func (s *Server) quikRecipientsLoop(ctx context.Context) { + t := time.NewTimer(time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + quikRecipientsRun(ctx, s.rc, "плановое") + t.Reset(quikRecipientsEvery) + } + } +} + +// quikRecipientsInfo — строка о справочнике получателей для страницы «Поручения QUIK». +func quikRecipientsInfo() string { + quikRecipientsState.Lock() + defer quikRecipientsState.Unlock() + st := struct { + At time.Time + Total, Added, Renamed int + Err string + }{quikRecipientsState.At, quikRecipientsState.Total, quikRecipientsState.Added, quikRecipientsState.Renamed, quikRecipientsState.Err} + switch { + case st.At.IsZero(): + return "Справочник депозитариев-получателей M2M в МНП: ещё не сверялся после запуска bj (сверка раз в 30 мин до 20:00)." + case st.Err != "": + return "Справочник депозитариев-получателей M2M в МНП: ошибка обновления " + st.At.Format("02.01 15:04") + " — " + st.Err + } + s := fmt.Sprintf("Справочник депозитариев-получателей M2M в МНП сверен со справочником НРД %s: %d депозитариев", st.At.Format("02.01 15:04"), st.Total) + if st.Added > 0 || st.Renamed > 0 { + s += fmt.Sprintf(" (добавлено %d, исправлено наименований %d — модуль МНП применит после ночного перезапуска)", st.Added, st.Renamed) + } + return s + ". Обновляется раз в 30 мин до 20:00." +} diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 8cceacc..d523a3e 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -331,6 +331,7 @@ func (s *Server) Run(ctx context.Context) error { } healthRegister("poll-quik", 30*time.Second) go superviseLoop(ctx, "poll-quik", s.rc, s.quikCycleLoop) + go superviseLoop(ctx, "quik-recipients", s.rc, s.quikRecipientsLoop) // справочник получателей M2M в МНП до 20:00 // Контроль сроков депо-поручений 16/16-1: MS010 за 5 мин, исполнение до 30 дней. healthRegister("order-watch", time.Minute) diff --git a/internal/lkgateway/web/templates/admin_quik_instructions.html b/internal/lkgateway/web/templates/admin_quik_instructions.html index 3c22df1..08b4915 100644 --- a/internal/lkgateway/web/templates/admin_quik_instructions.html +++ b/internal/lkgateway/web/templates/admin_quik_instructions.html @@ -1,5 +1,6 @@ {{define "content"}} {{if .Flash}}
{{.Flash}}
{{end}} +

{{quikRecipientsInfo}}