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:
+19
-18
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user