QUIK МНП: bj ведёт справочник депозитариев-получателей M2M (M2MDepoRecipients) по справочнику НРД — раз в 30 мин до 20:00 и при изменении справочника; состояние на «Поручения QUIK»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-09-29 13:45:31 +03:00
parent 7a84dd6d5b
commit adc22ef206
6 changed files with 246 additions and 18 deletions
+19 -18
View File
@@ -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
+37
View File
@@ -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)
}
}
+1
View File
@@ -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
+187
View File
@@ -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."
}
+1
View File
@@ -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)
@@ -1,5 +1,6 @@
{{define "content"}}
{{if .Flash}}<div style="padding:12px 16px;background:var(--ok-weak);border-radius:8px;margin-bottom:16px">{{.Flash}}</div>{{end}}
<p class="muted" style="font-size:12.5px;margin:0 0 10px">{{quikRecipientsInfo}}</p>
<div class="card">
<style>
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}