Первичная настройка, часть 1: префикс торгового счёта DEPO — настройка с подсказкой по учёту (был зашит «ВТНКЦ» в 7 запросах); шаг 8 — адрес ИШ вместо ONYX, профили prod-gost/prod-rsa (пункт prod уводил bj в имитацию), без неиспользуемого контейнера, перезапуск bj после смены контура; реквизиты НРД на шаге 4; точная проверка процедур bj2depo и таблиц Fancy M2M; QUIK — объекты M2M и справочник получателей, без зашитого IP; сертификаты из справочника ИШ (FindCertificates — заглушка), уведомления о сроке за 30/14/7/3/1 день; тексты мастера, нумерация, режим разработки; уникальные id оповещений с русскими заголовками
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -9,7 +9,7 @@ CRED=/etc/bj/backup-smb.cred
|
||||
STATUS=/var/lib/bj/backup-status.json
|
||||
[ -r "$CONF" ] && . "$CONF"
|
||||
SHARE=${SHARE:-}
|
||||
DIR=${DIR:-BJ_Backup/bj}
|
||||
DIR=${DIR:-bj-backup}
|
||||
KEEP_DAYS=${KEEP_DAYS:-14}
|
||||
TS=$(date +%Y-%m-%d_%H%M)
|
||||
W=$(mktemp -d /var/tmp/bj-backup.XXXXXX)
|
||||
|
||||
@@ -19,7 +19,7 @@ err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; }
|
||||
[ -r "$CONF" ] && . "$CONF"
|
||||
# хранилище из запроса (восстановление на новом сервере, проверка до сохранения) или из настроек
|
||||
SHARE_IN=$(j share); DIR_IN=$(j dir); USER_IN=$(j user); PASS_IN=$(j password)
|
||||
SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-BJ_Backup/bj}}
|
||||
SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-bj-backup}}
|
||||
case "$SHARE" in //*) ;; \\\\*) SHARE=$(printf '%s' "$SHARE" | tr '\\' '/') ;; ?*) SHARE="//$SHARE" ;; esac
|
||||
TMPCRED=""
|
||||
credfile() {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package lkgateway
|
||||
|
||||
// Префикс торгового счёта клиента в учёте DEPO (OD_CLIENTS.NUMBER). У Фонтвьель — «ВТНКЦ…»; у другого
|
||||
// депозитария счета могут называться иначе, поэтому префикс — настройка (Fancy.AccountPrefix), и запросы
|
||||
// к учёту берут его через accSQL. Подсказка — какие префиксы у открытых счетов этого учёта.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
|
||||
)
|
||||
|
||||
const defaultAccPrefix = "ВТНКЦ"
|
||||
|
||||
// accPrefixRe — только буквы, цифры и «_»: префикс подставляется в текст запроса.
|
||||
var accPrefixRe = regexp.MustCompile(`^[\p{L}\p{N}_]{1,20}$`)
|
||||
|
||||
func cleanAccPrefix(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if accPrefixRe.MatchString(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// depoAccPrefix — префикс торговых счетов из настроек (по умолчанию «ВТНКЦ»).
|
||||
func depoAccPrefix() string {
|
||||
if globalRC != nil {
|
||||
if p := cleanAccPrefix(globalRC.Snapshot().Fancy.AccountPrefix); p != "" {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return defaultAccPrefix
|
||||
}
|
||||
|
||||
// accSQL — запрос к учёту с подставленным префиксом торгового счёта.
|
||||
func accSQL(q string) string { return strings.ReplaceAll(q, "{ACC}", depoAccPrefix()) }
|
||||
|
||||
type accPrefixCount struct {
|
||||
Prefix string `json:"prefix"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// depoAccPrefixInfo — сколько открытых счетов с текущим префиксом и какие префиксы вообще есть.
|
||||
func depoAccPrefixInfo(ctx context.Context, f FancyMSSQLSettings) (cur int, cands []accPrefixCount, err error) {
|
||||
if f.IsPostgres() {
|
||||
return 0, nil, nil
|
||||
}
|
||||
db, err := fancyOpen(f, f.DepoDB())
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.QueryRowContext(ctx, accSQL(fsql.Acc02)).Scan(&cur); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, fsql.Acc01)
|
||||
if err != nil {
|
||||
return cur, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var c accPrefixCount
|
||||
if rows.Scan(&c.Prefix, &c.Count) == nil {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
return cur, cands, rows.Err()
|
||||
}
|
||||
|
||||
// GET /admin/setup/fancy/acc-prefix — текущий префикс и подсказка; POST — сохранить.
|
||||
func (h *setupHandlers) accPrefix(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
p := strings.TrimSpace(r.FormValue("account_prefix"))
|
||||
if p != "" && cleanAccPrefix(p) == "" {
|
||||
jsonOut(w, map[string]any{"ok": false, "error": "префикс — только буквы и цифры, до 20 знаков"})
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().Fancy
|
||||
s.AccountPrefix = p
|
||||
if err := h.rc.UpdateFancy(s); err != nil {
|
||||
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
s := h.rc.Snapshot().Fancy
|
||||
out := map[string]any{"ok": true, "prefix": depoAccPrefix(), "set": cleanAccPrefix(s.AccountPrefix) != ""}
|
||||
if strings.TrimSpace(s.Server) != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
cur, cands, err := depoAccPrefixInfo(ctx, s)
|
||||
if err != nil {
|
||||
out["error"] = quikErrorDetail(err)
|
||||
}
|
||||
out["count"], out["candidates"] = cur, cands
|
||||
}
|
||||
jsonOut(w, out)
|
||||
}
|
||||
@@ -58,6 +58,8 @@ var templateFuncs = template.FuncMap{
|
||||
"lkPortfolioStates": lkPortfolioStates,
|
||||
"backupStatus": backupStatus,
|
||||
"backupWhen": backupWhen,
|
||||
"depoAccPrefix": depoAccPrefix,
|
||||
"certDaysLeft": certDaysLeft,
|
||||
"intList": func(v ...int) []int { return v },
|
||||
"nsdSegTip": nsdSegTip,
|
||||
"devOn": authTemplateFuncs["devOn"],
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
// BackupSettings — куда и как часто копировать (без пароля).
|
||||
type BackupSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Share string `json:"share,omitempty"` // //10.10.10.11/Backup
|
||||
Dir string `json:"dir,omitempty"` // BJ_Backup/bj
|
||||
Share string `json:"share,omitempty"` // //nas/backup
|
||||
Dir string `json:"dir,omitempty"` // bj-backup
|
||||
User string `json:"user,omitempty"`
|
||||
PasswordSet bool `json:"password_set,omitempty"`
|
||||
EveryHours int `json:"every_hours,omitempty"`
|
||||
@@ -44,7 +44,7 @@ func (b BackupSettings) keep() int {
|
||||
return 14
|
||||
}
|
||||
|
||||
func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "BJ_Backup/bj") }
|
||||
func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "bj-backup") }
|
||||
|
||||
// BackupStatus — итог последней копии (пишет root-скрипт).
|
||||
type BackupStatus struct {
|
||||
@@ -117,7 +117,7 @@ func registerBackup(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
b.KeepDays, _ = strconv.Atoi(r.FormValue("keep_days"))
|
||||
pass := r.FormValue("password")
|
||||
if b.Share == "" {
|
||||
setupFlash(w, r, "Резервное копирование: укажите хранилище, например //10.10.10.11/Backup")
|
||||
setupFlash(w, r, "Резервное копирование: укажите хранилище, например //nas/backup")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
|
||||
|
||||
@@ -67,7 +67,11 @@ func (h *setupHandlers) resetLogo(w http.ResponseWriter, r *http.Request) {
|
||||
setupFlash(w, r, "Логотип: ошибка сброса: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Логотип сброшен на стандартный (встроенный Фонтвьель).")
|
||||
if builtinLogoAllowed() {
|
||||
setupFlash(w, r, "Логотип сброшен на стандартный (встроенный Фонтвьель).")
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Логотип сброшен: в отчётах логотипа нет, пока не загрузите свой.")
|
||||
}
|
||||
|
||||
// logoMIME определяет MIME изображения по расширению и содержимому; "" — не картинка.
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package lkgateway
|
||||
|
||||
// Сроки сертификатов в справочнике ИШ (05.10.2026). Своих сертификатов bj напрямую не видит (ключи — в
|
||||
// Валидате и ИШ), но знает содержимое справочника активного профиля ИШ: его раз в 6 часов читает сверка
|
||||
// сертификатов НРД (nsdcerts.go, NSDCerts.LastStore). Свой сертификат — тот, что не из комплекта НРД,
|
||||
// не самоподписанный (не УЦ) и выдан не НРД, бирже или НКЦ. По нему же отмечается шаг «Сертификаты».
|
||||
//
|
||||
// Уведомления: за 30, 14, 7, 3 и 1 день до окончания и в день окончания — для своего сертификата и для
|
||||
// сертификатов НРД, если им нет замены с более поздним сроком.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// ИНН организаций, чьи сертификаты лежат в справочнике рядом с нашим (без ведущих нулей).
|
||||
var counterpartINN = map[string]string{"7702165310": "НРД", "7702077840": "Московская Биржа", "7750004023": "НКЦ"}
|
||||
|
||||
var subjectINNRe = regexp.MustCompile(`(?:^|,)\s*INN=0*(\d{10,12})`)
|
||||
|
||||
func subjectINN(subject string) string {
|
||||
if m := subjectINNRe.FindStringSubmatch(subject); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// storeNotAfter — срок из справочника («02.01.2006»): сертификат действует весь последний день.
|
||||
func storeNotAfter(c NSDStoreCert) time.Time {
|
||||
t, err := time.ParseInLocation("02.01.2006", strings.TrimSpace(c.NotAfter), time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.Add(24*time.Hour - time.Second)
|
||||
}
|
||||
|
||||
func certDaysLeft(t time.Time) int {
|
||||
if t.IsZero() {
|
||||
return 1 << 20
|
||||
}
|
||||
d := time.Until(t)
|
||||
if d < 0 {
|
||||
return -1
|
||||
}
|
||||
return int(d.Hours() / 24)
|
||||
}
|
||||
|
||||
func bundleSerials() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
bundle, _ := loadNSDCertBundle()
|
||||
for _, b := range bundle {
|
||||
out[normSerial(b.Serial)] = true
|
||||
if b.ReplacesSerial != "" {
|
||||
out[normSerial(b.ReplacesSerial)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storeCertKind — own (свой), counterpart (НРД/биржа/НКЦ), ca (УЦ) или "" (не сертификат).
|
||||
func storeCertKind(c NSDStoreCert, bundle map[string]bool) string {
|
||||
if c.Kind != "x509" {
|
||||
return ""
|
||||
}
|
||||
if c.SelfSigned || strings.Contains(c.SubjectCN, "УЦ") {
|
||||
return "ca"
|
||||
}
|
||||
if bundle[normSerial(c.Serial)] || counterpartINN[subjectINN(c.Subject)] != "" {
|
||||
return "counterpart"
|
||||
}
|
||||
return "own"
|
||||
}
|
||||
|
||||
// ownStoreCerts — свои сертификаты в справочнике ИШ.
|
||||
func ownStoreCerts(s Settings) []NSDStoreCert {
|
||||
bundle := bundleSerials()
|
||||
var out []NSDStoreCert
|
||||
for _, c := range s.NSDCerts.LastStore {
|
||||
if storeCertKind(c, bundle) == "own" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storeCertsForUI — сертификаты справочника ИШ (кроме УЦ) для шага «Сертификаты»: свой — с отметкой «ключ есть».
|
||||
func storeCertsForUI(s Settings) []cryptocli.Certificate {
|
||||
bundle := bundleSerials()
|
||||
var out []cryptocli.Certificate
|
||||
seen := map[string]bool{}
|
||||
for _, c := range s.NSDCerts.LastStore {
|
||||
k := storeCertKind(c, bundle)
|
||||
if k == "" || k == "ca" || seen[c.Serial] {
|
||||
continue
|
||||
}
|
||||
seen[c.Serial] = true
|
||||
out = append(out, cryptocli.Certificate{SubjectCN: c.SubjectCN, IssuerCN: c.IssuerCN, Serial: c.Serial,
|
||||
NotAfter: storeNotAfter(c), INN: subjectINN(c.Subject), HasPrivateKey: k == "own"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var certExpiryMarks = []int{30, 14, 7, 3, 1, 0}
|
||||
|
||||
var certExpiry = struct {
|
||||
sync.Mutex
|
||||
file string
|
||||
}{file: "/var/lib/bj/.bj/cert-expiry.json"}
|
||||
|
||||
// certExpiryTick — разослать уведомления о сроках (каждая отметка — один раз на сертификат).
|
||||
func certExpiryTick(rc *RuntimeConfig) {
|
||||
s := rc.Snapshot()
|
||||
if len(s.NSDCerts.LastStore) == 0 {
|
||||
return
|
||||
}
|
||||
certExpiry.Lock()
|
||||
defer certExpiry.Unlock()
|
||||
sent := map[string][]int{}
|
||||
if raw, err := os.ReadFile(certExpiry.file); err == nil {
|
||||
_ = json.Unmarshal(raw, &sent)
|
||||
}
|
||||
bundle := bundleSerials()
|
||||
// сертификат НРД, которому уже есть замена с более поздним сроком, не тревожим
|
||||
latest := map[string]time.Time{}
|
||||
for _, c := range s.NSDCerts.LastStore {
|
||||
if t := storeNotAfter(c); c.Kind == "x509" && t.After(latest[c.SubjectCN]) {
|
||||
latest[c.SubjectCN] = t
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for _, c := range s.NSDCerts.LastStore {
|
||||
kind := storeCertKind(c, bundle)
|
||||
if kind != "own" && kind != "counterpart" {
|
||||
continue
|
||||
}
|
||||
until := storeNotAfter(c)
|
||||
if until.IsZero() || (kind == "counterpart" && latest[c.SubjectCN].After(until)) {
|
||||
continue
|
||||
}
|
||||
days := certDaysLeft(until)
|
||||
key := normSerial(c.Serial)
|
||||
mark := -1
|
||||
for _, m := range certExpiryMarks {
|
||||
if days <= m && !containsInt(sent[key], m) {
|
||||
mark = m // самая близкая к сроку из ещё не отправленных
|
||||
}
|
||||
}
|
||||
if mark < 0 {
|
||||
continue
|
||||
}
|
||||
for _, m := range certExpiryMarks {
|
||||
if m >= mark && !containsInt(sent[key], m) {
|
||||
sent[key] = append(sent[key], m)
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
title, body := certExpiryText(c, kind, days, until)
|
||||
log.Printf("cert-expiry: %s", title)
|
||||
raiseSystemAlert(rc, title, body)
|
||||
}
|
||||
if changed {
|
||||
if raw, err := json.MarshalIndent(sent, "", " "); err == nil {
|
||||
_ = os.WriteFile(certExpiry.file, raw, 0o600)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(a []int, v int) bool {
|
||||
for _, x := range a {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func certExpiryText(c NSDStoreCert, kind string, days int, until time.Time) (string, string) {
|
||||
when := fmt.Sprintf("через %d дн.", days)
|
||||
switch {
|
||||
case days < 0:
|
||||
when = "ИСТЁК"
|
||||
case days == 0:
|
||||
when = "истекает сегодня"
|
||||
}
|
||||
who := fmt.Sprintf("«%s» (издатель «%s», серийный номер %s)", c.SubjectCN, c.IssuerCN, c.Serial)
|
||||
if kind == "own" {
|
||||
title := "Сертификат ключа организации " + when
|
||||
if days > 0 {
|
||||
title = "Сертификат ключа организации истекает " + when
|
||||
}
|
||||
return title, fmt.Sprintf("Сертификат %s действует до %s включительно. После этой даты подписать пакеты для НРД будет нечем — переводы M2M остановятся. "+
|
||||
"Закажите новый ключ в Удостоверяющем центре Московской биржи заранее (выпуск занимает время) и установите его мастером установки ключа: Настройка → Криптография.",
|
||||
who, until.Format("02.01.2006"))
|
||||
}
|
||||
org := pickStr(counterpartINN[subjectINN(c.Subject)], "НРД")
|
||||
title := "Сертификат " + org + " в справочнике ИШ " + when
|
||||
if days > 0 {
|
||||
title = "Сертификат " + org + " в справочнике ИШ истекает " + when
|
||||
}
|
||||
return title, fmt.Sprintf("Сертификат %s действует до %s, замены с более поздним сроком в справочнике нет. Новые сертификаты НРД приезжают с обновлением bj — проверьте, что bj обновлён (Настройка → Обновления), и сверку сертификатов НРД (Настройка → Криптография).",
|
||||
who, until.Format("02.01.2006"))
|
||||
}
|
||||
|
||||
// StartCertExpiryWatch — проверка сроков раз в 6 часов (первая — через 3 минуты после запуска).
|
||||
func StartCertExpiryWatch(rc *RuntimeConfig) func() {
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTimer(3 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
certExpiryTick(rc)
|
||||
t.Reset(6 * time.Hour)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(stop) }
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func depoResolveParty(ctx context.Context, db *sql.DB, fs FancyMSSQLSettings, se
|
||||
|
||||
if p.faceOK {
|
||||
rows, e := db.QueryContext(ctx,
|
||||
fsql.Chk02,
|
||||
accSQL(fsql.Chk02),
|
||||
sql.Named("p1", p.faceID))
|
||||
if e == nil {
|
||||
for rows.Next() {
|
||||
@@ -379,7 +379,7 @@ func partyChecks(p depoParty, ser, num, fio string) []depoCheck {
|
||||
"паспорт найден, но ФИО не совпадает: в картотеке «"+p.faceName+"», в запросе «"+fio+"»"),
|
||||
"паспорт "+ser+" № "+num+" не найден в картотеке (OD_FACES)")},
|
||||
{Name: "Торговый счёт депо есть", OK: p.accAny != "", Code: "M2M02",
|
||||
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта ВТНКЦ… в OD_CLIENTS", "не проверяется — инвестор не найден"))},
|
||||
Detail: pick3(p.accAny != "", p.accAny, pick3(p.faceOK, "у инвестора нет счёта "+depoAccPrefix()+"… в OD_CLIENTS", "не проверяется — инвестор не найден"))},
|
||||
{Name: "Статус счёта строго «открыт»", OK: p.accOpen != "", Code: "M2M02",
|
||||
Detail: pick3(p.accAny == "", "не проверяется — счёта нет", pick3(p.accOpen != "", p.accOpen+" — открыт (STATE=0, без даты закрытия)", p.accAny+" — НЕ открыт (закрыт или STATE≠0)"))},
|
||||
{Name: "Счёт не ИИС", OK: p.iisOK, Code: "M2M08", Detail: p.iisDet},
|
||||
|
||||
@@ -37,7 +37,7 @@ func depoSimLists() (clients []depoSimClient, secs []depoSimSec) {
|
||||
defer cancel()
|
||||
|
||||
rows, err := db.QueryContext(ctx,
|
||||
fsql.Sim01)
|
||||
accSQL(fsql.Sim01))
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -218,7 +218,7 @@ func depoUpsertOrder(rc *RuntimeConfig, guid, direction, cpCode, deponentName, d
|
||||
// ранний (то же правило, что при зачислении в QUIK); джоба по номеру находит OD_CLIENTS.ID (DEPO).
|
||||
var clientAcc sql.NullString
|
||||
if serN != "" && strings.TrimSpace(num) != "" {
|
||||
_ = db.QueryRowContext(ctx, fsql.Ev03,
|
||||
_ = db.QueryRowContext(ctx, accSQL(fsql.Ev03),
|
||||
sql.Named("s", serN), sql.Named("n", strings.TrimSpace(num))).Scan(&clientAcc)
|
||||
}
|
||||
hasDepoNumber := depoHasColumn(ctx, db, "FTV_M2M_LINES", "DEPO_NUMBER")
|
||||
@@ -958,6 +958,9 @@ func depoProcCheck(ctx context.Context, rc *RuntimeConfig) (string, bool) {
|
||||
if !depoTableExists(ctx, db, "FTV_M2M_EVENTS") {
|
||||
return "в базе " + f.DepoDB() + " нет таблиц FTV_M2M*", false
|
||||
}
|
||||
if msg := depoObjectsMissing(ctx, db, f); msg != "" {
|
||||
return msg, false
|
||||
}
|
||||
if ok, msg := depoRunAsCheck(ctx, db, f); !ok {
|
||||
return msg, false
|
||||
}
|
||||
@@ -1215,7 +1218,7 @@ func depoSimulateIncoming(ctx context.Context, rc *RuntimeConfig, it *IncomingTr
|
||||
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||||
numN := strings.TrimSpace(num)
|
||||
var name string
|
||||
if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
|
||||
if err := db.QueryRowContext(ctx, accSQL(fsql.Ev11), sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
|
||||
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
|
||||
}
|
||||
guid := strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", time.Now().Unix()&0xFFFFFFFF, time.Now().UnixNano()&0xFFFF, time.Now().UnixNano()&0xFFFFFFFFFFFF))
|
||||
@@ -1284,7 +1287,7 @@ func depoSimulateOutgoing(ctx context.Context, rc *RuntimeConfig, svc *Service,
|
||||
serN := strings.ReplaceAll(strings.TrimSpace(ser), " ", "")
|
||||
numN := strings.TrimSpace(num)
|
||||
var name string
|
||||
if err := db.QueryRowContext(ctx, fsql.Ev11, sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
|
||||
if err := db.QueryRowContext(ctx, accSQL(fsql.Ev11), sql.Named("s", serN), sql.Named("n", numN)).Scan(&name); err != nil {
|
||||
return "", fmt.Errorf("клиент с паспортом %s %s и открытым торговым счётом в DEPO не найден", ser, num)
|
||||
}
|
||||
req := d.Request
|
||||
@@ -1400,3 +1403,38 @@ func mapInt(m map[string]any, k string) int64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Объекты в базе DEPO, без которых обработка событий не работает: таблицы Fancy с поддержкой M2M и
|
||||
// процедуры расширения bj2depo (deploy/depo/fancy-m2m). Проверяются по имени сразу — без 40-секундного
|
||||
// ожидания BJ_PING и без общего «не обработано».
|
||||
var (
|
||||
depoFancyM2MTables = []string{"D_CLISER_M2M", "D_CLISER_M2M_DOL", "OD_M2M_ACCS", "OD_ONLINE_M2M_DEPO"}
|
||||
depoBj2depoProcs = []string{"FTV_M2M_JOB", "FTV_M2M_PROCESS_CREATE", "FTV_M2M_PROCESS_EVENTS", "FTV_M2M_PROCESS_OPERATION", "FTV_M2M_PROCESS_WIRING"}
|
||||
)
|
||||
|
||||
func depoObjectsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string {
|
||||
if f.IsPostgres() {
|
||||
return ""
|
||||
}
|
||||
missing := func(names []string, typ string) []string {
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
var ok int
|
||||
if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if m := missing(depoFancyM2MTables, "U"); len(m) > 0 {
|
||||
return "в базе " + f.DepoDB() + " нет таблиц Fancy DEPO с поддержкой M2M (" + strings.Join(m, ", ") + "): нужна версия Fancy DEPO с M2M — обратитесь к разработчику Fancy"
|
||||
}
|
||||
procs := depoBj2depoProcs
|
||||
if p := strings.TrimSpace(f.DepoJobProc); p != "" && !strings.EqualFold(strings.TrimPrefix(p, "dbo."), "FTV_M2M_JOB") {
|
||||
procs = []string{strings.TrimPrefix(p, "dbo.")} // своя процедура обработки — проверяем только её
|
||||
}
|
||||
if m := missing(procs, "P"); len(m) > 0 {
|
||||
return "в базе " + f.DepoDB() + " нет процедур обработки bj2depo: " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package fsql
|
||||
|
||||
// Acc01 — начала номеров открытых счетов в OD_CLIENTS (буквы до первой цифры) и их число: подсказка, какой
|
||||
// префикс у торговых счетов клиентов в этом учёте.
|
||||
var Acc01 = `SELECT TOP 8 p, COUNT(*) FROM (SELECT LEFT(NUMBER, PATINDEX('%[0-9]%', NUMBER + '0') - 1) AS p
|
||||
FROM OD_CLIENTS WHERE STATE=0 AND D_CLOSE IS NULL AND NUMBER IS NOT NULL) x
|
||||
WHERE p <> '' GROUP BY p ORDER BY COUNT(*) DESC`
|
||||
|
||||
// Acc02 — число открытых счетов с выбранным префиксом.
|
||||
var Acc02 = `SELECT COUNT(*) FROM OD_CLIENTS WHERE NUMBER LIKE N'{ACC}%' AND STATE=0 AND D_CLOSE IS NULL`
|
||||
@@ -3,7 +3,7 @@ package fsql
|
||||
var Chk01 = `SELECT TOP 1 ISNULL(NULLIF(SELF_ID,0),ID), ISNULL(NAME,''), DATE_BIRTH FROM OD_FACES WHERE REPLACE(ISNULL(DOC_SER,''),' ','')=@p1 AND DOC_NUM=@p2
|
||||
ORDER BY ISNULL(LAST_FLAG,0) DESC, ID DESC`
|
||||
|
||||
var Chk02 = `SELECT ID, ISNULL(NUMBER,''), ISNULL(STATE,-1), D_CLOSE FROM OD_CLIENTS WHERE FACE=@p1 AND NUMBER LIKE N'ВТНКЦ%' ORDER BY ID`
|
||||
var Chk02 = `SELECT ID, ISNULL(NUMBER,''), ISNULL(STATE,-1), D_CLOSE FROM OD_CLIENTS WHERE FACE=@p1 AND NUMBER LIKE N'{ACC}%' ORDER BY ID`
|
||||
|
||||
var Chk03 = `SELECT TOP 1 CAST(o.VAL AS varchar(20)) FROM OD_OPTIONS o
|
||||
JOIN OP_FIELDS f ON f.ID = o.DESCR AND f.AS_NAME = 'IS_QUAL'
|
||||
|
||||
@@ -2,7 +2,7 @@ package fsql
|
||||
|
||||
var Sim01 = `SELECT TOP 25 ISNULL(f.NAME,''), ISNULL(f.DOC_SER,''), ISNULL(f.DOC_NUM,''), ISNULL(c.NUMBER,'')
|
||||
FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID
|
||||
WHERE c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
|
||||
WHERE c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL
|
||||
AND LEN(ISNULL(f.DOC_SER,''))>0 AND LEN(ISNULL(f.DOC_NUM,''))>0
|
||||
ORDER BY f.NAME`
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ var Ev02 = `INSERT INTO dbo.FTV_M2M_EVENTS (GUID, REFERENCE, EVENT_TYPE, EVENT_D
|
||||
|
||||
var Ev03 = `SELECT TOP 1 c.NUMBER FROM OD_FACES f
|
||||
JOIN OD_CLIENTS c ON c.FACE=f.ID LEFT JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL
|
||||
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`
|
||||
|
||||
var Ev04 = `INSERT INTO dbo.FTV_M2M (GUID, DIRECTION, CONTRAGENT_M2M_CODE, DEPONENT_NAME, DEPONENT_DOC) VALUES (@g, @d, @cp, @nm, @doc)`
|
||||
@@ -28,4 +28,4 @@ var Ev10 = `INSERT INTO dbo.FTV_M2M_HANDBOOK (INN, PARTICIPANT_CODE, KIND, NAME_
|
||||
VALUES (@inn, @code, @kind, @full, @short, @disp, @eng, @at, GETDATE(), 1)`
|
||||
|
||||
var Ev11 = `SELECT TOP 1 f.NAME FROM OD_FACES f JOIN OD_CLIENTS c ON c.FACE=f.ID
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0`
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@s AND f.DOC_NUM=@n AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0`
|
||||
|
||||
@@ -4,12 +4,12 @@ var Man01 = `SELECT TOP 20 ISNULL(NULLIF(f.SELF_ID,0),f.ID), ISNULL(f.NAME,''),
|
||||
f.DOC_DAT, ISNULL(f.DOC_PLACE,''), ISNULL(f.DOC_CODE,''), ISNULL(f.PHONE,'')
|
||||
FROM OD_FACES f
|
||||
WHERE ISNULL(f.LAST_FLAG,1)=1 AND (UPPER(f.NAME) LIKE UPPER(@q) OR f.DOC_NUM=@d OR REPLACE(ISNULL(f.DOC_SER,''),' ','')+f.DOC_NUM=@d)
|
||||
AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
|
||||
AND EXISTS (SELECT 1 FROM OD_CLIENTS c WHERE c.FACE=ISNULL(NULLIF(f.SELF_ID,0),f.ID) AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL)
|
||||
ORDER BY f.NAME`
|
||||
|
||||
var Man02 = `SELECT c.ID, ISNULL(c.NUMBER,''),
|
||||
ISNULL((SELECT TOP 1 p.PORT_NUM FROM OD_CLIENTS_PORTS p WHERE p.CLIENT=c.ID ORDER BY p.PORT_DATE, p.PORT_NUM),'')
|
||||
FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`
|
||||
FROM OD_CLIENTS c WHERE c.FACE=@f AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0 AND c.D_CLOSE IS NULL ORDER BY c.ID`
|
||||
|
||||
var Man03 = `SELECT ISNULL(v.ISIN,''), ISNULL(v.NAME,''), SUM(CAST(r.K AS float))
|
||||
FROM OD_RESTS r JOIN OD_VALUES v ON v.ID=r.VALUE_ID
|
||||
|
||||
@@ -5,5 +5,5 @@ var Qk01 = `SELECT ic.ClassCode, sec.SecCode FROM ISecurities sec JOIN IClasses
|
||||
|
||||
var Qk02 = `SELECT TOP 5 p.PORT_NUM FROM OD_FACES f
|
||||
JOIN OD_CLIENTS c ON c.FACE=f.ID JOIN OD_CLIENTS_PORTS p ON p.CLIENT=c.ID
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'ВТНКЦ%' AND c.STATE=0
|
||||
WHERE REPLACE(f.DOC_SER,' ','')=@p1 AND f.DOC_NUM=@p2 AND c.NUMBER LIKE N'{ACC}%' AND c.STATE=0
|
||||
ORDER BY ISNULL(p.PORT_DATE,'2099-01-01'), ISNULL(c.D_OPEN,'2099-01-01'), c.NUMBER`
|
||||
|
||||
@@ -3,6 +3,7 @@ package lkgateway
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
@@ -247,8 +248,11 @@ func slugify(s string) string {
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "alert"
|
||||
if out == "" || strings.Count(out, "-") == len(out) {
|
||||
// заголовки по-русски дают пустую строку — без хеша разные оповещения за 10 минут склеивались бы в одно
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return fmt.Sprintf("a%08x", h.Sum32())
|
||||
}
|
||||
if len(out) > 40 {
|
||||
out = out[:40]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package lkgateway
|
||||
|
||||
// Интеграционный шлюз НРД (ИШ, служба igate) на этом сервере: состояние для мастера (шаг 8).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const igateDefaultURL = "http://localhost:8090"
|
||||
|
||||
// igateInstalled — пакет ИШ стоит на этом сервере.
|
||||
func igateInstalled() bool {
|
||||
_, err := os.Stat("/opt/nsd/igate")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// igateState — отвечает ли ИШ и в каком состоянии его движок.
|
||||
func igateState(ctx context.Context, base string) (bool, string) {
|
||||
base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/")
|
||||
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/engine/state", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if !igateInstalled() {
|
||||
return false, "интеграционный шлюз на этом сервере не установлен"
|
||||
}
|
||||
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "igate").Output()
|
||||
return false, "шлюз установлен, но не отвечает по адресу " + base + " (служба igate: " + pickStr(strings.TrimSpace(string(out)), "неизвестно") + ")"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
st := strings.Trim(strings.TrimSpace(string(body)), `"`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, "шлюз отвечает с ошибкой HTTP " + resp.Status
|
||||
}
|
||||
if st != "Running" {
|
||||
return false, "шлюз отвечает, но его движок в состоянии «" + st + "» — нужен Running"
|
||||
}
|
||||
return true, "шлюз отвечает по адресу " + base + ", движок работает"
|
||||
}
|
||||
|
||||
func registerIgate(mux *http.ServeMux, rc *RuntimeConfig) {
|
||||
mux.HandleFunc("/admin/setup/igate/state", func(w http.ResponseWriter, r *http.Request) {
|
||||
ok, msg := igateState(r.Context(), rc.Snapshot().NSD.IGWBaseURL)
|
||||
jsonOut(w, map[string]any{"ok": ok, "msg": msg, "installed": igateInstalled()})
|
||||
})
|
||||
}
|
||||
@@ -176,7 +176,7 @@ type LKPortfolioSyncSettings struct {
|
||||
}
|
||||
|
||||
func (s LKPortfolioSyncSettings) accountPrefix() string {
|
||||
return pickStr(strings.TrimSpace(s.AccountPrefix), "ВТНКЦ")
|
||||
return pickStr(strings.TrimSpace(s.AccountPrefix), depoAccPrefix())
|
||||
}
|
||||
func (s LKPortfolioSyncSettings) contractTemplate() string {
|
||||
return pickStr(strings.TrimSpace(s.ContractTemplate), "ДД-{n}")
|
||||
|
||||
@@ -73,7 +73,7 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
|
||||
// Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID).
|
||||
// likeEscape: метасимволы LIKE (% _ [) в имени от пользователя означают сами себя,
|
||||
// иначе поиск «расползается» по всей базе (wildcard-injection).
|
||||
rows, err := db.QueryContext(ctx, fsql.Man01, sql.Named("q", likeEscape(q)+"%"), sql.Named("d", digits))
|
||||
rows, err := db.QueryContext(ctx, accSQL(fsql.Man01), sql.Named("q", likeEscape(q)+"%"), sql.Named("d", digits))
|
||||
if err != nil {
|
||||
return nil, "поиск в DEPO: " + err.Error()
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
|
||||
rows.Close()
|
||||
out := make([]manualDepoFace, 0, len(found))
|
||||
for _, r := range found {
|
||||
ar, err := db.QueryContext(ctx, fsql.Man02, sql.Named("f", r.id))
|
||||
ar, err := db.QueryContext(ctx, accSQL(fsql.Man02), sql.Named("f", r.id))
|
||||
if err == nil {
|
||||
for ar.Next() {
|
||||
var a manualDepoAcc
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// outboundIP — адрес этого сервера, с которого он ходит к host (для отчётов: какой IP разрешить на той стороне).
|
||||
// UDP-«соединение» пакетов не шлёт — система только выбирает маршрут и исходящий адрес.
|
||||
func outboundIP(host string, port int) string {
|
||||
h := strings.TrimSpace(strings.SplitN(host, `\`, 2)[0])
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if port == 0 {
|
||||
port = 1433
|
||||
}
|
||||
c, err := net.DialTimeout("udp", net.JoinHostPort(h, strconv.Itoa(port)), time.Second)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer c.Close()
|
||||
if a, ok := c.LocalAddr().(*net.UDPAddr); ok {
|
||||
return a.IP.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAccPrefix(t *testing.T) {
|
||||
for in, want := range map[string]string{"ВТНКЦ": "ВТНКЦ", " БР ": "БР", "AB_1": "AB_1", "a'b": "", "x%": "", "": ""} {
|
||||
if got := cleanAccPrefix(in); got != want {
|
||||
t.Errorf("cleanAccPrefix(%q)=%q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if q := accSQL("LIKE N'{ACC}%'"); !strings.Contains(q, "N'ВТНКЦ%'") {
|
||||
t.Errorf("accSQL по умолчанию: %s", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoneNSDUnknownProfile(t *testing.T) {
|
||||
var s Settings
|
||||
s.NSD.IGWBaseURL, s.NSD.Profile = "http://localhost:8090", "prod"
|
||||
if doneNSD(s) {
|
||||
t.Fatal("профиль prod неизвестен bj — шаг не должен считаться выполненным")
|
||||
}
|
||||
s.NSD.Profile = "prod-gost"
|
||||
if !doneNSD(s) {
|
||||
t.Fatal("prod-gost — известный профиль")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCertKindAndExpiry(t *testing.T) {
|
||||
bundle := map[string]bool{}
|
||||
own := NSDStoreCert{Kind: "x509", Subject: "O=Предприятие 1046,CN=Сотрудник 1046,INN=078000001046", SubjectCN: "Сотрудник 1046", IssuerCN: "Тестовый УЦ", Serial: "AA01"}
|
||||
nsd := NSDStoreCert{Kind: "x509", Subject: "O=НКО АО НРД,CN=Петров,INN=007702165310", SubjectCN: "Петров", IssuerCN: "Тестовый УЦ", Serial: "BB02"}
|
||||
ca := NSDStoreCert{Kind: "x509", SubjectCN: "Тестовый УЦ", SelfSigned: true}
|
||||
for c, want := range map[*NSDStoreCert]string{&own: "own", &nsd: "counterpart", &ca: "ca"} {
|
||||
if got := storeCertKind(*c, bundle); got != want {
|
||||
t.Errorf("%s: %s, want %s", c.SubjectCN, got, want)
|
||||
}
|
||||
}
|
||||
if subjectINN(nsd.Subject) != "7702165310" {
|
||||
t.Errorf("subjectINN: %q", subjectINN(nsd.Subject))
|
||||
}
|
||||
|
||||
// уведомления: за 20 дней — одно (отметка 30), повторно не шлётся; за 5 дней — следующее (7)
|
||||
dir := t.TempDir()
|
||||
certExpiry.file = filepath.Join(dir, "cert-expiry.json")
|
||||
rc, err := NewRuntimeConfig(filepath.Join(dir, "setup.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own.NotAfter = time.Now().AddDate(0, 0, 20).Format("02.01.2006")
|
||||
st := rc.Snapshot().NSDCerts
|
||||
st.LastStore = []NSDStoreCert{own, nsd, ca}
|
||||
_ = rc.UpdateNSDCerts(st)
|
||||
count := func() int { return len(rc.Snapshot().News.Items) }
|
||||
certExpiryTick(rc)
|
||||
n1 := count()
|
||||
certExpiryTick(rc)
|
||||
if n1 != 1 || count() != 1 {
|
||||
t.Fatalf("за 20 дней: ожидалось одно уведомление, было %d, затем %d", n1, count())
|
||||
}
|
||||
own.NotAfter = time.Now().AddDate(0, 0, 5).Format("02.01.2006")
|
||||
st.LastStore = []NSDStoreCert{own}
|
||||
_ = rc.UpdateNSDCerts(st)
|
||||
certExpiryTick(rc)
|
||||
if count() != 2 {
|
||||
t.Fatalf("за 5 дней: ожидалось второе уведомление, всего %d", count())
|
||||
}
|
||||
raw, _ := os.ReadFile(certExpiry.file)
|
||||
if !strings.Contains(string(raw), "7") {
|
||||
t.Fatalf("отметка 7 не сохранена: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -113,3 +113,30 @@ func (h *setupHandlers) saveOrg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
|
||||
var nsdReqRe = regexp.MustCompile(`^[A-Z0-9]{4,30}$`)
|
||||
|
||||
// saveNSDRequisites — POST /admin/setup/nsd-requisites: депозитарный код, счёт и раздел организации в НРД
|
||||
// (место хранения бумаг в пакетах). В мастере — на шаге «Организация», рядом с кодом и ИНН.
|
||||
func (h *setupHandlers) saveNSDRequisites(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().NSD
|
||||
dep := strings.ToUpper(strings.TrimSpace(r.FormValue("deponent_code")))
|
||||
acc := strings.ToUpper(strings.TrimSpace(r.FormValue("account_id")))
|
||||
sec := strings.ToUpper(strings.TrimSpace(r.FormValue("section_id")))
|
||||
for _, v := range []struct{ name, val string }{{"депозитарный код", dep}, {"депозитарный счёт", acc}, {"раздел счёта", sec}} {
|
||||
if v.val != "" && !nsdReqRe.MatchString(v.val) {
|
||||
setupFlash(w, r, "Реквизиты НРД: "+v.name+" — латинские буквы и цифры, без пробелов")
|
||||
return
|
||||
}
|
||||
}
|
||||
s.DeponentCode, s.AccountID, s.SectionID = dep, acc, sec
|
||||
if err := h.rc.UpdateNSD(s); err != nil {
|
||||
setupFlash(w, r, "Реквизиты НРД: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Реквизиты НРД сохранены: код "+pick(dep, "—")+", счёт "+pick(acc, "—")+", раздел "+pick(sec, "—"))
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@ func quikResolveClient(ctx context.Context, rc *RuntimeConfig, ser, num string)
|
||||
// Правило депозитария: при нескольких договорах клиента зачисление идёт на
|
||||
// САМЫЙ РАННИЙ договор — берём портфель с наименьшей датой (затем по дате
|
||||
// открытия счёта и номеру).
|
||||
rows, err := db.QueryContext(cctx, fsql.Qk02, serN, numN)
|
||||
rows, err := db.QueryContext(cctx, accSQL(fsql.Qk02), serN, numN)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("DEPO: %w", err)
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(&b, "БД: %s\n", dfltDB(s.Database))
|
||||
fmt.Fprintf(&b, "Логин: %s\n", s.User)
|
||||
fmt.Fprintf(&b, "Шифрование: encrypt=%s, TrustServerCertificate=%v\n", s.Encrypt, s.Trust)
|
||||
fmt.Fprintf(&b, "Клиент (bj): исходящий IP 195.239.71.22, драйвер go-mssqldb\n")
|
||||
fmt.Fprintf(&b, "Клиент (bj): исходящий IP %s, драйвер go-mssqldb\n", pickStr(outboundIP(s.Server, s.Port), "не определён"))
|
||||
fmt.Fprintf(&b, "----------------------------------------\n")
|
||||
|
||||
res, err := quikProbe(r.Context(), s)
|
||||
@@ -218,9 +218,23 @@ func (h *setupHandlers) testQuik(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(&b, " %-28s %s\n", k, mark)
|
||||
}
|
||||
s.Verified = true
|
||||
s.M2MMissing = ""
|
||||
if !res.M2MReady {
|
||||
var miss []string
|
||||
for _, k := range []string{"table:Export", "table:Contracts", "proc:sp_NewInstrSec"} {
|
||||
if !res.Objects[k] {
|
||||
miss = append(miss, strings.SplitN(k, ":", 2)[1])
|
||||
}
|
||||
}
|
||||
s.M2MMissing = strings.Join(miss, ", ")
|
||||
}
|
||||
s.LastReport = b.String()
|
||||
s.LastAt = now.UTC()
|
||||
_ = h.rc.UpdateQuik(s)
|
||||
if s.M2MMissing != "" {
|
||||
setupFlash(w, r, fmt.Sprintf("QUIK МНП: подключение есть (БД=%s), но под этим логином не видно объектов M2M: %s. Проверьте имя базы и права логина (роль InstrEu). Полный отчёт ниже.", res.DBName, s.M2MMissing))
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, fmt.Sprintf("QUIK МНП: ПОДКЛЮЧЕНИЕ OK (БД=%s). Полный отчёт ниже.", res.DBName))
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ type QuikMNPSettings struct {
|
||||
Verified bool `json:"verified"` // последнее тест-подключение прошло
|
||||
LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам)
|
||||
LastAt time.Time `json:"last_at"` // время последнего теста
|
||||
M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке
|
||||
|
||||
// Автоматический цикл M2M в МНП (пишущие операции к боевой БД).
|
||||
WritesEnabled bool `json:"writes_enabled"` // главный предохранитель: без него ни одной записи в МНП
|
||||
@@ -179,6 +180,9 @@ type FancyMSSQLSettings struct {
|
||||
User string `json:"user"` // логин
|
||||
Password string `json:"password"` // пароль
|
||||
Encrypt string `json:"encrypt"` // MSSQL: disable|false|true; Postgres: sslmode
|
||||
// AccountPrefix — начало номера торгового счёта клиента в OD_CLIENTS (у Фонтвьель «ВТНКЦ»): по нему bj
|
||||
// находит счёт инвестора при сверке переводов, в цикле QUIK и при ручном вводе. Пусто — «ВТНКЦ».
|
||||
AccountPrefix string `json:"account_prefix,omitempty"`
|
||||
|
||||
// Обработка трансферных таблиц DEPO силами bj (вместо джобы SQL Agent bj2depo):
|
||||
// bj сам вызывает процедуру DepoJobProc каждые DepoJobEverySec секунд.
|
||||
|
||||
@@ -216,6 +216,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
registerJournal(mux, adminTpl, svc, incomingStore)
|
||||
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
|
||||
registerBackup(mux, rc) // резервное копирование и восстановление (backup.go)
|
||||
registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go)
|
||||
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
||||
registerHealth(mux)
|
||||
registerQuikOps(mux, rc, svc)
|
||||
@@ -365,7 +366,9 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
// Доверенные сертификаты НРД: вшитый комплект → справочник активного
|
||||
// профиля ИШ (добавление/плановое удаление, с бэкапом и откатом).
|
||||
stopNSDCerts := StartNSDCertsSync(s.rc)
|
||||
stopCertExpiry := StartCertExpiryWatch(s.rc)
|
||||
defer stopNSDCerts()
|
||||
defer stopCertExpiry()
|
||||
|
||||
// Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и
|
||||
// эмитирует новости в ленту. Дефолтные источники + дефолтные
|
||||
|
||||
+38
-21
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||
)
|
||||
|
||||
// crypto-сертификаты на текущих токенах (для отображения на странице).
|
||||
@@ -28,20 +29,7 @@ import (
|
||||
// Валидаты — здесь временно возвращаем пустой список, пока
|
||||
// crypto-service не получит метод ListCertificates.
|
||||
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
|
||||
s := h.rc.Snapshot()
|
||||
if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" {
|
||||
return nil
|
||||
}
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||
SocketPath: s.Crypto.SocketPath,
|
||||
ModulePath: s.Crypto.ModulePath,
|
||||
})
|
||||
defer cli.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
certs, _ := cli.FindCertificates(ctx)
|
||||
return certs
|
||||
return storeCertsForUI(h.rc.Snapshot())
|
||||
}
|
||||
|
||||
// setupHandlers — обработчики /admin/setup/*.
|
||||
@@ -84,6 +72,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/wizard-depo/tables", h.wizardDepoTables)
|
||||
mux.HandleFunc("/admin/setup/wizard-depo/job", h.wizardDepoJob)
|
||||
mux.HandleFunc("/admin/setup/wizard-depo/recheck", h.wizardDepoRecheck)
|
||||
mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix)
|
||||
mux.HandleFunc("/admin/setup/license", h.saveLicense)
|
||||
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow)
|
||||
@@ -91,6 +80,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/org", h.saveOrg)
|
||||
mux.HandleFunc("/admin/setup/nsd-requisites", h.saveNSDRequisites)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/lk/dist", h.lkSaveDist)
|
||||
mux.HandleFunc("/admin/setup/lk/dist.tar.gz", h.lkDistDownload)
|
||||
@@ -251,8 +241,8 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
|
||||
// Валидата ставится с образа/установщиком: если она на месте, шаг выполнен без ввода параметров
|
||||
// (bj работает с ней через свой криптосервис, путь к модулю не нужен).
|
||||
d.Done.Crypto = d.ValidataVer != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")
|
||||
d.Done.Certs = len(d.Certs) > 0
|
||||
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
|
||||
d.Done.Certs = len(ownValidCerts(d.Certs)) > 0
|
||||
d.Done.NSD = doneNSD(s)
|
||||
d.Done.TestRun = s.LastTest != nil || (!d.DevMode && d.Done.NSD) // вне режима разработки тестовой заявки нет
|
||||
|
||||
// Мастер идёт по порядку: каждый шаг показывается, даже если он уже выполнен (установщиком,
|
||||
@@ -272,7 +262,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
|
||||
step = wizardSteps
|
||||
}
|
||||
d.Step = step
|
||||
if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && (orgCode(s) == "" || orgINN(s) == "") && depoDetectResult() == nil {
|
||||
if step == 4 && strings.TrimSpace(s.Fancy.Server) != "" && !doneOrg(s) && depoDetectResult() == nil {
|
||||
// организация ещё не задана, а база DEPO подключена — определяем реквизиты сами: карточка
|
||||
// покажет их заполненными, пользователю остаётся проверить и сохранить
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
@@ -296,8 +286,8 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
|
||||
d.Sub = "done"
|
||||
}
|
||||
}
|
||||
d.Done.Org = orgCode(s) != "" && orgINN(s) != ""
|
||||
d.Done.Quik = !d.DepoLicensed || s.Quik.Verified
|
||||
d.Done.Org = doneOrg(s)
|
||||
d.Done.Quik = !d.DepoLicensed || doneQuik(s)
|
||||
if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" {
|
||||
// Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку
|
||||
// запускает сам bj. Иначе по умолчанию джобу SQL Agent создаёт bj.
|
||||
@@ -721,6 +711,22 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
SectionID: strings.TrimSpace(r.FormValue("section_id")),
|
||||
IGWv904: r.FormValue("igw_v904") == "on",
|
||||
}
|
||||
cur := h.rc.Snapshot().NSD
|
||||
if r.FormValue("keep_requisites") == "1" { // мастер: реквизиты НРД — на шаге «Организация»
|
||||
s.DeponentCode, s.AccountID, s.SectionID = cur.DeponentCode, cur.AccountID, cur.SectionID
|
||||
}
|
||||
if _, ok := r.Form["key_container"]; !ok {
|
||||
s.KeyContainer = cur.KeyContainer
|
||||
}
|
||||
if s.Profile == "prod" { // старый пункт мастера: такого профиля нет — bj ушёл бы в имитацию
|
||||
s.Profile = "prod-gost"
|
||||
}
|
||||
if s.Profile != "" {
|
||||
if _, err := nsdadapter.LookupProfile(s.Profile); err != nil {
|
||||
setupFlash(w, r, "НРД: неизвестный контур "+s.Profile)
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.IGWBaseURL != "" {
|
||||
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
|
||||
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
|
||||
@@ -728,7 +734,7 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API.
|
||||
if err := tryHTTPHealth(s.IGWBaseURL + "/api/admin/engine/state"); err != nil {
|
||||
setupFlash(w, r, "nsd: ИШ не отвечает (engine/state): "+err.Error())
|
||||
setupFlash(w, r, "Шлюз НРД не отвечает по адресу "+s.IGWBaseURL+": "+err.Error()+". Проверьте, что ИШ установлен и запущен (служба igate).")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -736,7 +742,18 @@ func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "nsd-adapter: настройки сохранены")
|
||||
msg := "Шлюз НРД: настройки сохранены"
|
||||
if s.Profile != cur.Profile || s.IGWBaseURL != cur.IGWBaseURL {
|
||||
// отправитель в НРД создаётся при запуске — перезапускаем bj, чтобы контур и адрес применились сразу
|
||||
msg += ". bj перезапускается, чтобы применить контур и адрес, — обновите страницу через 10–15 секунд."
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
if _, err := restartUnit(context.Background(), "bj-server"); err != nil {
|
||||
log.Printf("setup: перезапуск bj-server после смены контура НРД: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
setupFlash(w, r, msg)
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||
)
|
||||
|
||||
type SetupStep struct {
|
||||
@@ -35,15 +36,7 @@ func setupProbes(rc *RuntimeConfig) (string, int) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
setupProbeCache.validata, _, _ = validataVersion(ctx)
|
||||
setupProbeCache.certs = 0
|
||||
s := rc.Snapshot()
|
||||
if s.Crypto.Provider != "" && s.Crypto.Provider != "stub" {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.Provider(s.Crypto.Provider), SocketPath: s.Crypto.SocketPath, ModulePath: s.Crypto.ModulePath})
|
||||
if certs, err := cli.FindCertificates(ctx); err == nil {
|
||||
setupProbeCache.certs = len(certs)
|
||||
}
|
||||
cli.Close()
|
||||
}
|
||||
setupProbeCache.certs = len(ownValidCerts(storeCertsForUI(rc.Snapshot())))
|
||||
setupProbeCache.at = time.Now()
|
||||
return setupProbeCache.validata, setupProbeCache.certs
|
||||
}
|
||||
@@ -60,14 +53,44 @@ func setupSteps(rc *RuntimeConfig) []SetupStep {
|
||||
if mnp {
|
||||
steps = append(steps, SetupStep{3, "Учёт DEPO", strings.TrimSpace(s.Fancy.Server) != "" && s.Fancy.DepoTablesCheckOK && s.Fancy.DepoJobCheckOK})
|
||||
}
|
||||
steps = append(steps, SetupStep{4, "Наша организация", orgCode(s) != "" && orgINN(s) != ""})
|
||||
steps = append(steps, SetupStep{4, "Наша организация", doneOrg(s)})
|
||||
if mnp {
|
||||
steps = append(steps, SetupStep{5, "QUIK МНП", s.Quik.Verified})
|
||||
steps = append(steps, SetupStep{5, "QUIK МНП", doneQuik(s)})
|
||||
}
|
||||
steps = append(steps,
|
||||
SetupStep{6, "Валидата", validata != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")},
|
||||
SetupStep{7, "Сертификаты", certs > 0},
|
||||
SetupStep{8, "Шлюз НРД", s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""},
|
||||
SetupStep{8, "Шлюз НРД", doneNSD(s)},
|
||||
)
|
||||
return steps
|
||||
}
|
||||
|
||||
// ownValidCerts — свои сертификаты (с закрытым ключом), срок которых не истёк: только они делают шаг
|
||||
// «Сертификаты» выполненным (сертификаты НРД и УЦ в хранилище есть и без ключа организации).
|
||||
func ownValidCerts(certs []cryptocli.Certificate) []cryptocli.Certificate {
|
||||
var out []cryptocli.Certificate
|
||||
now := time.Now()
|
||||
for _, c := range certs {
|
||||
if c.HasPrivateKey && (c.NotAfter.IsZero() || c.NotAfter.After(now)) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// doneOrg — организация и её депозитарные реквизиты в НРД заданы (без них перевод не сформировать).
|
||||
func doneOrg(s Settings) bool {
|
||||
return orgCode(s) != "" && orgINN(s) != "" && s.NSD.DeponentCode != "" && s.NSD.AccountID != "" && s.NSD.SectionID != ""
|
||||
}
|
||||
|
||||
// doneQuik — подключение к МНП проверено и ключевые объекты M2M видны.
|
||||
func doneQuik(s Settings) bool { return s.Quik.Verified && s.Quik.M2MMissing == "" }
|
||||
|
||||
// doneNSD — адрес ИШ задан и профиль известен bj (неизвестный профиль — молча режим имитации).
|
||||
func doneNSD(s Settings) bool {
|
||||
if s.NSD.IGWBaseURL == "" || s.NSD.Profile == "" {
|
||||
return false
|
||||
}
|
||||
_, err := nsdadapter.LookupProfile(s.NSD.Profile)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{{else}}
|
||||
<table style="margin-top:10px">
|
||||
<tr><td class="muted" style="width:220px">Инвестор в картотеке</td><td>{{if .Result.Investor}}<b>{{.Result.Investor}}</b>{{else}}<span class="dc-bad">не найден</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Result.Account}}<code>{{.Result.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого ВТНКЦ-счёта нет</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Result.Account}}<code>{{.Result.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого торгового счёта нет</span>{{end}}</td></tr>
|
||||
</table>
|
||||
{{range .Result.Secs}}
|
||||
<h3 style="font-size:13.5px;margin:14px 0 4px"><code>{{.Security}}</code> → {{if eq .Recommend "confirm"}}<span class="dc-ok">принять</span>{{else}}<span class="dc-bad">отказ {{.RejectCode}}</span> <span class="muted" style="font-weight:400">({{m2mCode .RejectCode}})</span>{{end}}</h3>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
{{else}}
|
||||
<table style="margin-top:10px">
|
||||
<tr><td class="muted" style="width:220px">Инвестор в картотеке</td><td>{{if .Depo.Investor}}<b>{{.Depo.Investor}}</b>{{else}}<span class="dc-bad">не найден</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Depo.Account}}<code>{{.Depo.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого ВТНКЦ-счёта нет</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Торговый счёт депо</td><td>{{if .Depo.Account}}<code>{{.Depo.Account}}</code> — открыт{{else}}<span class="dc-bad">открытого торгового счёта нет</span>{{end}}</td></tr>
|
||||
</table>
|
||||
{{range .Depo.Secs}}
|
||||
<span class="depo-rec" data-ref="{{.ReferenceID}}" data-rec="{{.Recommend}}" data-code="{{.RejectCode}}" hidden></span>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
{{if not .Depo.Ran}}
|
||||
<p class="muted" style="margin-top:8px">Сверка не выполнена: {{.Depo.Err}}</p>
|
||||
{{else}}
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">Инвестор: {{if .Depo.Investor}}<b>{{.Depo.Investor}}</b>{{else}}<span class="dc-bad">не найден в картотеке</span>{{end}} · Торговый счёт: {{if .Depo.Account}}<code>{{.Depo.Account}}</code>{{else}}<span class="dc-bad">нет открытого ВТНКЦ-счёта</span>{{end}}</div>
|
||||
<div class="muted" style="font-size:12.5px;margin-top:8px">Инвестор: {{if .Depo.Investor}}<b>{{.Depo.Investor}}</b>{{else}}<span class="dc-bad">не найден в картотеке</span>{{end}} · Торговый счёт: {{if .Depo.Account}}<code>{{.Depo.Account}}</code>{{else}}<span class="dc-bad">нет открытого торгового счёта</span>{{end}}</div>
|
||||
{{range .Depo.Secs}}
|
||||
<div style="font-size:13px;font-weight:600;margin:10px 0 4px"><code>{{.Security}}</code> → {{if eq .Recommend "confirm"}}<span class="dc-ok">списать можно</span>{{else}}<span class="dc-bad">отказ {{.RejectCode}}</span> <span class="muted" style="font-weight:400">({{m2mCode .RejectCode}})</span>{{end}}</div>
|
||||
<table style="font-size:12.5px">
|
||||
|
||||
@@ -654,7 +654,7 @@
|
||||
<form method="post" action="/admin/setup/lk-portfolio" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;margin-top:8px">
|
||||
<input type="hidden" name="naming" value="1">
|
||||
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px">Префикс торгового счёта в DEPO
|
||||
<input name="account_prefix" value="{{.Settings.LK.PortfolioSync.AccountPrefix}}" placeholder="ВТНКЦ" style="width:120px"></label>
|
||||
<input name="account_prefix" value="{{.Settings.LK.PortfolioSync.AccountPrefix}}" placeholder="{{depoAccPrefix}}" style="width:120px"></label>
|
||||
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px">Номер договора депо в ЛК ({n} — номер счёта)
|
||||
<input name="contract_template" value="{{.Settings.LK.PortfolioSync.ContractTemplate}}" placeholder="ДД-{n}" style="width:160px"></label>
|
||||
<label style="display:flex;flex-direction:column;gap:3px;font-size:12px">Раздел счёта
|
||||
@@ -684,7 +684,7 @@
|
||||
function pick(c){
|
||||
document.getElementById('lkpf-port').value=c.port; document.getElementById('lkpf-lk').value=c.lk;
|
||||
var ok=!!c.lk; document.getElementById('lkpf-push').disabled=!ok; document.getElementById('lkpf-add').disabled=!ok;
|
||||
sel.innerHTML=ok?('Выбран: <b>'+esc(c.name)+'</b> · портфель DEPO '+esc(c.port)+' · счёт '+esc(c.account)+' → договор в ЛК <b>'+esc(c.lk)+'</b>'):'У клиента нет торгового счёта ВТНКЦ — договор в ЛК не определить';
|
||||
sel.innerHTML=ok?('Выбран: <b>'+esc(c.name)+'</b> · портфель DEPO '+esc(c.port)+' · счёт '+esc(c.account)+' → договор в ЛК <b>'+esc(c.lk)+'</b>'):'У клиента нет торгового счёта — договор в ЛК не определить';
|
||||
list.innerHTML='';
|
||||
}
|
||||
q.addEventListener('input',function(){
|
||||
@@ -1001,8 +1001,8 @@
|
||||
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
|
||||
<label class="chk" style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" style="width:auto"{{if $b.Enabled}} checked{{end}}> <b>Делать копии по расписанию</b></label>
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
|
||||
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//10.10.10.11/Backup"></label>
|
||||
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="BJ_Backup/bj"></label>
|
||||
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//nas/backup"></label>
|
||||
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
|
||||
|
||||
@@ -57,8 +57,8 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<style>#rs-form label:not(.chk){display:block;margin:0}#rs-form label:not(.chk) input,#rs-form label:not(.chk) select{width:100%;box-sizing:border-box;margin-top:4px}#rs-pick label{flex:1;min-width:220px}</style>
|
||||
<form id="rs-form" style="display:grid;gap:8px;max-width:640px" onsubmit="return false">
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:8px">
|
||||
<label>Хранилище (SMB)<input name="share" placeholder="//10.10.10.11/Backup" value="{{.Settings.Backup.Share}}"></label>
|
||||
<label>Папка<input name="dir" placeholder="BJ_Backup/bj" value="{{.Settings.Backup.Dir}}"></label>
|
||||
<label>Хранилище (SMB)<input name="share" placeholder="//nas/backup" value="{{.Settings.Backup.Share}}"></label>
|
||||
<label>Папка<input name="dir" placeholder="bj-backup" value="{{.Settings.Backup.Dir}}"></label>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label>Логин хранилища<input name="user" autocomplete="off" value="{{.Settings.Backup.User}}"></label>
|
||||
@@ -155,7 +155,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
{{if eq $sub "intro"}}
|
||||
<div class="help-block"><strong>Что это.</strong> Подключение bj к базе депозитарного учёта DEPO (Fancy).<br>
|
||||
<strong>Зачем.</strong> bj сверяет с учётом каждый перевод (клиент, счёт, остаток, квалификация) и сам передаёт в DEPO поручения и их исполнение — документы в учёте создаются автоматически. Отсюда же bj возьмёт реквизиты вашей организации (шаг 4).<br>
|
||||
<strong>Что понадобится.</strong> Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. Процедуру обработки обмена в базе DEPO ставит разработчик системы учёта Fancy; если её ещё нет, bj скажет об этом на проверке.<br>
|
||||
<strong>Что понадобится.</strong> Адрес сервера базы DEPO, логин и пароль — их даёт ваш администратор баз данных. Для создания таблиц обмена и джобы обработки может понадобиться разово логин администратора SQL — он не сохраняется. В базе DEPO должны быть процедуры обработки обмена (расширение bj2depo) и версия Fancy DEPO с поддержкой M2M — bj проверит их по имени и скажет, чего не хватает.<br>
|
||||
<strong>Как пойдём.</strong> Подключение → таблицы обмена → обработка → проверка. Каждый шаг проверяется сразу.</div>
|
||||
<div style="margin-top:14px"><a href="/admin/wizard?step=3&sub=conn" class="btn" style="text-decoration:none">Начать →</a></div>
|
||||
|
||||
@@ -268,6 +268,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<tr><td>Запуск обработки (джоба)</td><td>{{if .Settings.Fancy.DepoJobCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoJobCheck}}</span>{{else}}<span style="color:var(--err)">✗ {{.Settings.Fancy.DepoJobCheck}}</span>{{end}}</td></tr>
|
||||
<tr><td>Обработка событий (процедуры)</td><td>{{if .Settings.Fancy.DepoProcCheckOK}}<span style="color:var(--ok)">✓ {{.Settings.Fancy.DepoProcCheck}}</span>{{else if .Settings.Fancy.DepoProcCheck}}<span style="color:var(--warn)">⚠ {{.Settings.Fancy.DepoProcCheck}}</span>{{else}}<span class="muted">не проверялась — «Перепроверить всё»</span>{{end}}</td></tr>
|
||||
</table>
|
||||
{{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}}
|
||||
{{if .Done.Depo}}<p>Учёт DEPO подключён и проверен. Можно переходить к шагу 4 — там bj предложит реквизиты вашей организации из DEPO.</p>{{else}}<p class="muted" style="font-size:13px">Не всё проверено — можно продолжить настройку (шаг 4 и дальше) и вернуться сюда позже. Пока обработка не работает, записи bj ждут в таблицах обмена со статусом TODO и будут обработаны, когда она заработает.</p>{{end}}
|
||||
<form method="post" action="/admin/setup/wizard-depo/recheck" style="margin:0"><button type="submit" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Перепроверить всё</button></form>
|
||||
{{end}}
|
||||
@@ -277,7 +278,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<a href="/admin/wizard?step=4" class="btn" style="{{if not .Done.Depo}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Depo}}К шагу 4 →{{else}}Пропустить шаг →{{end}}</a></div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* ============= ШАГ 1: PostgreSQL ============= */}}
|
||||
{{/* ============= ШАГ 2: База bj (PostgreSQL) ============= */}}
|
||||
{{if eq .Step 2}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>Шаг 2. База данных bj (PostgreSQL)</h2>
|
||||
@@ -285,7 +286,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
{{if .Done.Postgres}}<p>✓ База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно, переходите к следующему шагу.</p>{{end}}
|
||||
|
||||
{{if not .Done.Postgres}}<div class="help-block">
|
||||
<strong>Что выбрать?</strong> Если у вас уже есть рабочий PostgreSQL — нажмите «У меня уже есть PostgreSQL» и введите DSN. Если впервые настраиваете — выберите «Поднять автоматически», система сама развернёт контейнер с PostgreSQL и накатит миграции.
|
||||
<strong>Обычно базу создаёт установщик</strong> — если вы ставили bj с образа или командой установки, этот шаг уже выполнен. Если база bj на отдельном сервере PostgreSQL — введите строку подключения ниже{{if devOn}}, или поднимите локальную базу автоматически (режим разработки){{end}}.
|
||||
</div>{{end}}
|
||||
|
||||
{{if and devOn (not .Settings.Postgres.DSN)}}
|
||||
@@ -311,9 +312,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=1" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 1</a>
|
||||
{{if .Done.Postgres}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<button class="btn" disabled style="opacity:0.5;cursor:not-allowed">К шагу 2 → (сначала настройте PostgreSQL или нажмите «in-memory режим»)</button>{{end}}
|
||||
{{if .Done.Postgres}}<a href="/admin/wizard?step=3" class="btn" style="text-decoration:none">К шагу 3 →</a>{{else}}<button class="btn" disabled style="opacity:0.5;cursor:not-allowed">К шагу 3 → (сначала подключите базу)</button>{{end}}
|
||||
</div>
|
||||
{{if not .Done.Postgres}}<p style="margin-top:8px"><a href="/admin/wizard?step=3&skip=postgres" style="font-size:13px">Пропустить (буду работать в режиме in-memory — без сохранения сделок)</a></p>{{end}}
|
||||
{{if and devOn (not .Done.Postgres)}}<p style="margin-top:8px"><a href="/admin/wizard?step=3&skip=postgres" style="font-size:13px">Пропустить (режим разработки: in-memory — без сохранения сделок)</a></p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -325,6 +326,20 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<strong>Откуда взять.</strong> Если на шаге 3 подключена база DEPO, bj сам определяет реквизиты по учёту — проверьте их и сохраните. Иначе выберите себя из справочника участников НРД или введите вручную.</div>
|
||||
{{if .Suggest}}{{if .Suggest.OrgName}}{{if not .Done.Org}}<p style="margin:8px 0;padding:8px 12px;border-radius:8px;background:var(--accent-weak)">✓ <strong>Определено из DEPO</strong>: {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}, код {{.Suggest.OrgCode}}{{if .Suggest.AccountID}}, счёт НРД {{.Suggest.AccountID}}{{end}}. Поля ниже заполнены — проверьте и нажмите «Сохранить».</p>{{end}}{{end}}{{end}}
|
||||
{{with .Fancy}}{{template "orgSection" .}}{{end}}
|
||||
<div class="card" id="nsd-req" style="margin-top:12px">
|
||||
<h2><span class="dot {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}ok{{else}}err{{end}}"></span>Депозитарные реквизиты в НРД</h2>
|
||||
<p class="muted" style="margin:0 0 8px">Где в НРД учитываются бумаги вашей организации: место хранения указывается в каждом пакете перевода. Берутся из договора с НРД{{if .Settings.Fancy.Server}} — или кнопкой «Определить из DEPO»{{end}}.</p>
|
||||
{{if .Suggest}}{{if .Suggest.AccountID}}<div class="help-block" style="margin:0 0 8px"><strong>Найдено в DEPO:</strong> счёт {{.Suggest.AccountID}}, код {{.Suggest.DeponentCode}}, раздел {{.Suggest.SectionID}}.{{range .Suggest.Notes}}<br><span class="muted">⚠ {{.}}</span>{{end}}</div>{{end}}{{end}}
|
||||
<form method="post" action="/admin/setup/nsd-requisites" style="display:grid;gap:10px;max-width:680px">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=4">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px">
|
||||
<label style="margin:0">Депозитарный код<input type="text" name="deponent_code" value="{{if .Settings.NSD.DeponentCode}}{{.Settings.NSD.DeponentCode}}{{else if .Suggest}}{{.Suggest.DeponentCode}}{{end}}" placeholder="MC0000000000" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
<label style="margin:0">Депозитарный счёт<input type="text" name="account_id" value="{{if .Settings.NSD.AccountID}}{{.Settings.NSD.AccountID}}{{else if .Suggest}}{{.Suggest.AccountID}}{{end}}" placeholder="HL000000000C" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
<label style="margin:0">Раздел счёта<input type="text" name="section_id" value="{{if .Settings.NSD.SectionID}}{{.Settings.NSD.SectionID}}{{else if .Suggest}}{{.Suggest.SectionID}}{{end}}" placeholder="36MC0000000000F00" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn">Сохранить</button>{{if .Settings.Fancy.Server}}<button type="submit" class="btn" formaction="/admin/setup/fancy/detect" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Определить из DEPO</button>{{end}}</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card" style="margin-top:12px">
|
||||
<h3 style="margin:0 0 6px">Логотип организации (необязательно)</h3>
|
||||
<p class="muted" style="margin:0 0 10px;font-size:13px">Показывается справа в шапке отчётов для регулятора. Можно загрузить сейчас или позже в «Настройка → Оформление».</p>
|
||||
@@ -350,7 +365,9 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<strong>Если МНП у вас ещё нет.</strong> Обратитесь в ARQA Technologies (<a href="https://arqatech.com" target="_blank" rel="noopener">arqatech.com</a>, служба поддержки QUIK) и попросите подключить модуль неторговых поручений для работы с внешней системой. После подключения ARQA выдаст адрес сервера МНП и учётные данные — их и нужно ввести ниже.<br>
|
||||
<strong>Если МНП уже есть.</strong> Адрес сервера и учётные данные — у вашего администратора QUIK.</div>
|
||||
{{if not .DepoLicensed}}<p class="muted">Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.</p>
|
||||
{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}}{{end}}
|
||||
{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}}
|
||||
{{if .Settings.Quik.M2MMissing}}<p style="color:var(--err);margin:8px 0">✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.</p>{{end}}
|
||||
<div class="help-block" style="margin-top:12px"><strong>Справочник депозитариев-получателей.</strong> Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (<code>M2MDepoRecipients</code>): bj заполняет его сам по справочнику участников НРД. <b>Модуль МНП читает справочник только при запуске</b> — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».<br><span class="muted">{{quikRecipientsInfo}}</span></div>{{end}}
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=4" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 4</a>
|
||||
<a href="/admin/wizard?step=6" class="btn" style="{{if not .Done.Quik}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Quik}}К шагу 6 →{{else}}Пропустить →{{end}}</a>
|
||||
@@ -358,14 +375,14 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 2: Крипто (Валидата) ============= */}}
|
||||
{{/* ============= ШАГ 6: Валидата ============= */}}
|
||||
{{if eq .Step 6}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Crypto}}ok{{else}}err{{end}}"></span>Шаг 6. СКЗИ «Валидата Клиент L»</h2>
|
||||
<p>СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 5).</p>
|
||||
<p>СКЗИ «Валидата Клиент L» проверяет подписи НРД на квитанциях и сообщениях и нужно для работы с ключами и сертификатами (шаг 7).</p>
|
||||
{{if .ValidataVer}}
|
||||
<p>✓ Валидата установлена вместе с bj: версия {{.ValidataVer}}, каталог <code>/opt/Validata/VDCSP</code>. Ничего настраивать на этом шаге не нужно.</p>
|
||||
<p class="muted" style="font-size:13px">bj работает с Валидатой через свою службу криптографии <code>bj-crypto</code>{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 5{{end}}.</p>
|
||||
<p class="muted" style="font-size:13px">bj работает с Валидатой через свою службу криптографии <code>bj-crypto</code>{{if eq .CryptoSvc "active"}} — она запущена{{else}} — сейчас она не запущена: служба ставится обновлением с сервера лицензий (шаг 1, кнопка «Обновить сейчас»), а работает с ключом, установленным на шаге 7{{end}}.</p>
|
||||
{{else}}
|
||||
<p style="color:var(--err)">Валидата на этом сервере не найдена.</p>
|
||||
<div class="help-block">
|
||||
@@ -384,7 +401,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 3: Сертификаты ============= */}}
|
||||
{{/* ============= ШАГ 7: Сертификаты ============= */}}
|
||||
{{if eq .Step 7}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Certs}}ok{{else}}err{{end}}"></span>Шаг 7. Сертификаты</h2>
|
||||
@@ -404,7 +421,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<strong>Откуда берутся.</strong> НРД и Московская биржа публикуют сертификаты в постоянных местах: комплект ГОСТ МБ, НКЦ и НРД — <code>https://fs.moex.com/cdp/sert/GOST.zip</code>, сертификаты УЦ — <code>https://fs.moex.com/cdp/</code>. Когда там появляется новый сертификат, мы добавляем его в комплект bj, и он приезжает к вам с обновлением.<br>
|
||||
Состояние, ручная проверка и выключение — «Настройка → Криптография → Сертификаты НРД в справочнике ИШ».
|
||||
</div>
|
||||
{{if .Settings.NSDCerts.LastRows}}<p class="muted" style="font-size:13px">Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.</p>{{else}}<p class="muted" style="font-size:13px">Первая сверка пройдёт, когда будут настроены ключ и шлюз НРД (шаг 6).</p>{{end}}
|
||||
{{if .Settings.NSDCerts.LastRows}}<p class="muted" style="font-size:13px">Последняя сверка: {{.Settings.NSDCerts.LastRun.Format "02.01.2006 15:04"}}{{if .Settings.NSDCerts.LastProfile}}, профиль ИШ {{.Settings.NSDCerts.LastProfile}}{{end}}.</p>{{else}}<p class="muted" style="font-size:13px">Первая сверка пройдёт, когда будут настроены ключ и шлюз НРД (шаг 8).</p>{{end}}
|
||||
|
||||
{{if .Certs}}
|
||||
<h3 style="margin-top:18px">Установленные сертификаты ({{len .Certs}})</h3>
|
||||
@@ -415,7 +432,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
<tr>
|
||||
<td>{{.SubjectCN}}</td>
|
||||
<td>{{.IssuerCN}}</td>
|
||||
<td>{{.NotAfter.Format "02.01.2006"}}</td>
|
||||
<td>{{.NotAfter.Format "02.01.2006"}}{{if .HasPrivateKey}}{{$d := certDaysLeft .NotAfter}}{{if lt $d 0}} <span style="color:var(--err)">истёк</span>{{else if le $d 30}} <span style="color:var(--warn)">осталось {{$d}} дн.</span>{{end}}{{end}}</td>
|
||||
<td>{{if .INN}}<code>{{.INN}}</code>{{else}}—{{end}}</td>
|
||||
<td>{{if .HasPrivateKey}}<span style="color:var(--ok)">есть</span>{{else}}<span class="muted">нет</span>{{end}}</td>
|
||||
</tr>
|
||||
@@ -433,61 +450,48 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* ============= ШАГ 4: НРД ============= */}}
|
||||
{{/* ============= ШАГ 8: Шлюз НРД ============= */}}
|
||||
{{if eq .Step 8}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Шаг 8. Интеграционный шлюз НРД</h2>
|
||||
<p>Адрес web-сервиса ONYX и имя ключевого контейнера НРД.</p>
|
||||
|
||||
<h2><span class="dot {{if .Done.NSD}}ok{{else}}err{{end}}"></span>Шаг 8. Интеграционный шлюз НРД (ИШ)</h2>
|
||||
<div class="help-block">
|
||||
<strong>Что это?</strong> Интеграционный шлюз (ИШ) НРД — это компонент, через который наши M2M-сообщения отправляются в НРД. {{if devOn}}У НРД есть 4 контура: <em>GUEST</em> (для разработки) и <em>TEST3</em> (предпродакшен), каждый в варианте ГОСТ или RSA.{{else}}У НРД есть тестовый контур (<em>TEST3</em>) и боевой контур, каждый в варианте ГОСТ или RSA.{{end}}<br>
|
||||
<strong>К кому обратиться.</strong> Программу ИШ выпускает НРД (дистрибутив и инструкции — <a href="https://www.nsd.ru/workflow/system/programs/" target="_blank" rel="noopener">nsd.ru/workflow/system/programs</a>). Доступ к контурам НРД (сначала тестовый TEST3, затем боевой) выдаёт НРД по заявке: обратитесь к вашему менеджеру НРД или в клиентскую поддержку по электронному документообороту. НРД сообщит ваш депозитарный код, счёт и раздел — если учёт в DEPO подключён, bj подставит их сам.
|
||||
<strong>Что это.</strong> Интеграционный шлюз — программа НРД на этом же сервере: через неё bj отправляет пакеты M2M в НРД и получает ответы. bj работает со шлюзом по его адресу на сервере (обычно <code>http://localhost:8090</code>).<br>
|
||||
<strong>К кому обратиться.</strong> Дистрибутив ИШ выпускает НРД (<a href="https://www.nsd.ru/workflow/system/programs/" target="_blank" rel="noopener">nsd.ru/workflow/system/programs</a>). Доступ к контурам — сначала тестовому TEST3, затем боевому — НРД открывает по заявке: обратитесь к своему менеджеру НРД или в поддержку электронного документооборота.
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div>
|
||||
<label>Профиль <span class="tooltip" title="{{if devOn}}GUEST — гостевой контур для разработчиков (gost-gt.nsd.ru), {{end}}TEST3 — тестовый контур НРД (gost-t3.nsd.ru), prod — боевой контур НРД">?</span></label>
|
||||
<select name="profile" id="nsd-profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>TEST3 · ГОСТ — тестовый контур НРД</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>TEST3 · RSA — тестовый контур НРД</option>
|
||||
{{if or devOn (eq .Settings.NSD.Profile "guest-gost") (eq .Settings.NSD.Profile "guest-rsa")}} <option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA</option>{{end}}
|
||||
<option value="prod" {{if eq .Settings.NSD.Profile "prod"}}selected{{end}}>prod — боевой контур НРД (осторожно)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>URL ONYX <span class="tooltip" title="Базовый URL веб-сервиса ONYX. При выборе профиля выше — заполняется автоматически.">?</span></label>
|
||||
<input type="text" name="igw_base_url" id="nsd-url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label>Ключевой контейнер НРД <span class="tooltip" title="Имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД)">?</span></label>
|
||||
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="nrd-edo" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%">
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin:4px 0">
|
||||
<p class="muted" style="margin:0">Депозитарные реквизиты организации в НРД. {{if .Settings.Fancy.Server}}Их можно взять из базы DEPO (Fancy): кнопка «Определить из DEPO» подставит найденное в пустые поля — проверьте и сохраните.{{else}}Если учёт ведётся в Fancy DEPO — сначала подключите базу DEPO на <a href="/admin/wizard?step=3">шаге 3</a>, тогда реквизиты подставятся из DEPO автоматически; иначе введите их из договора с НРД.{{end}}</p>
|
||||
{{if .Settings.Fancy.Server}}<button type="submit" class="btn" formaction="/admin/setup/fancy/detect" formmethod="post" style="justify-self:start;background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Определить из DEPO</button>{{end}}
|
||||
{{if .Suggest}}{{if .Suggest.AccountID}}<div class="help-block" style="margin:0"><strong>Найдено в DEPO:</strong> счёт {{.Suggest.AccountID}}, код {{.Suggest.DeponentCode}}, раздел {{.Suggest.SectionID}}{{if .Suggest.OrgName}}; организация {{.Suggest.OrgName}}, ИНН {{.Suggest.OrgINN}}{{end}}.{{range .Suggest.Notes}}<br><span class="muted">⚠ {{.}}</span>{{end}}</div>{{end}}{{end}}
|
||||
<div><label>Депозитарный код</label><input type="text" name="deponent_code" value="{{if .Settings.NSD.DeponentCode}}{{.Settings.NSD.DeponentCode}}{{else if .Suggest}}{{.Suggest.DeponentCode}}{{end}}" placeholder="MC0000000000" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%"></div>
|
||||
<div><label>Депозитарный счёт</label><input type="text" name="account_id" value="{{if .Settings.NSD.AccountID}}{{.Settings.NSD.AccountID}}{{else if .Suggest}}{{.Suggest.AccountID}}{{end}}" placeholder="HL000000000C" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%"></div>
|
||||
<div><label>Раздел счёта</label><input type="text" name="section_id" value="{{if .Settings.NSD.SectionID}}{{.Settings.NSD.SectionID}}{{else if .Suggest}}{{.Suggest.SectionID}}{{end}}" placeholder="36MC0000000000F00" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;width:100%"></div>
|
||||
<button type="submit" class="btn" style="justify-self:start">Сохранить</button>
|
||||
<p id="igw-state" class="muted" style="margin:8px 0">Проверяю шлюз…</p>
|
||||
{{template "igateInstall" .}}
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=8">
|
||||
{{if .Settings.NSD.IGWv904}}<input type="hidden" name="igw_v904" value="on">{{end}}
|
||||
<input type="hidden" name="keep_requisites" value="1">
|
||||
<label style="margin:0">Контур НРД
|
||||
<select name="profile" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px">
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>Тестовый TEST3 · ГОСТ</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>Тестовый TEST3 · RSA</option>
|
||||
{{if or devOn (eq .Settings.NSD.Profile "guest-gost") (eq .Settings.NSD.Profile "guest-rsa")}} <option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>GUEST · ГОСТ (для разработки)</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>GUEST · RSA (для разработки)</option>
|
||||
{{end}} <option value="prod-gost" {{if or (eq .Settings.NSD.Profile "prod-gost") (eq .Settings.NSD.Profile "prod")}}selected{{end}}>Боевой · ГОСТ</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>Боевой · RSA</option>
|
||||
</select></label>
|
||||
<label style="margin:0">Адрес шлюза на этом сервере
|
||||
<input type="text" name="igw_base_url" value="{{if .Settings.NSD.IGWBaseURL}}{{.Settings.NSD.IGWBaseURL}}{{else}}http://localhost:8090{{end}}" placeholder="http://localhost:8090" style="padding:8px;width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;margin-top:4px"></label>
|
||||
<p id="igw-onyx" style="display:none;color:var(--err);font-size:13px;margin:0">Это адрес веб-сервиса НРД (ONYX), а не шлюза: bj работает через ИШ на этом сервере. Укажите адрес шлюза — обычно <a href="#" id="igw-def">http://localhost:8090</a>.</p>
|
||||
<p class="muted" style="font-size:12.5px;margin:0">По «Сохранить» bj проверит, что шлюз отвечает по этому адресу.</p>
|
||||
<script>(function(){var u=document.querySelector('[name=igw_base_url]'),w=document.getElementById('igw-onyx');
|
||||
function chk(){w.style.display=/onyx|nsd\.ru/i.test(u.value)?'block':'none'} u.addEventListener('input',chk); chk();
|
||||
document.getElementById('igw-def').onclick=function(e){e.preventDefault();u.value='http://localhost:8090';chk()};})();</script>
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
</form>
|
||||
<p class="muted" style="font-size:13px;margin-top:10px">Организация (код участника, ИНН, наименование) — на <a href="/admin/wizard?step=4">шаге 4</a>.</p>
|
||||
<p class="muted" style="font-size:13px;margin-top:12px">Депозитарные реквизиты в НРД: {{if and .Settings.NSD.DeponentCode .Settings.NSD.AccountID .Settings.NSD.SectionID}}код <code>{{.Settings.NSD.DeponentCode}}</code>, счёт <code>{{.Settings.NSD.AccountID}}</code>, раздел <code>{{.Settings.NSD.SectionID}}</code>{{else}}<span style="color:var(--err)">не заданы</span>{{end}} — на <a href="/admin/wizard?step=4#nsd-req">шаге 4</a>.</p>
|
||||
<script>
|
||||
// Автозаполнение URL по выбранному профилю
|
||||
document.getElementById('nsd-profile').addEventListener('change', function(e){
|
||||
var urls = {
|
||||
'test3-gost': 'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'test3-rsa': 'https://rsa-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'guest-gost': 'https://gost-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'guest-rsa': 'https://rsa-gt.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo',
|
||||
'prod': ''
|
||||
};
|
||||
var u = document.getElementById('nsd-url');
|
||||
if (urls[e.target.value]) u.value = urls[e.target.value];
|
||||
});
|
||||
(function(){
|
||||
var el=document.getElementById('igw-state');
|
||||
fetch('/admin/setup/igate/state',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||
el.className=''; el.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+d.msg+'</span>':'<span style="color:var(--err)">✗ '+d.msg+'</span>';
|
||||
document.dispatchEvent(new CustomEvent('igate-state',{detail:d}));
|
||||
}).catch(function(){ el.textContent='Состояние шлюза не получено.'; });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between">
|
||||
<a href="/admin/wizard?step=7" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 7</a>
|
||||
{{if .Done.NSD}}<a href="/admin/wizard?step=9" class="btn" style="text-decoration:none">К шагу 9 →</a>{{else}}<a href="/admin/wizard?step=9&skip=nsd" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить ({{if devOn}}mock-режим{{else}}обмен с НРД не настроен{{end}}) →</a>{{end}}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{define "accPrefix"}}
|
||||
<div class="acc-prefix" style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:10px 0;max-width:680px">
|
||||
<div style="font-weight:600;margin-bottom:4px">Торговые счета клиентов в DEPO</div>
|
||||
<p class="muted" style="font-size:12.5px;margin:0 0 8px">По началу номера счёта в <code>OD_CLIENTS</code> bj находит счёт инвестора: при проверке перевода, в цикле QUIK и при ручном вводе. Если префикс неверный, все входящие переводы получат отказ «нет счёта».</p>
|
||||
<form class="acc-form" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap" onsubmit="return false">
|
||||
<input name="account_prefix" list="acc-cands" placeholder="{{depoAccPrefix}}" style="width:160px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<datalist id="acc-cands"></datalist>
|
||||
<button type="button" class="btn acc-save" style="padding:5px 12px">Сохранить</button>
|
||||
<span class="acc-out" style="font-size:13px"></span>
|
||||
</form>
|
||||
<div class="acc-cands muted" style="font-size:12.5px;margin-top:6px"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var box=document.currentScript.previousElementSibling, f=box.querySelector('.acc-form'), out=box.querySelector('.acc-out'), cl=box.querySelector('.acc-cands'), dl=box.querySelector('datalist');
|
||||
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||
function show(d){
|
||||
if(!d.ok){ out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; return; }
|
||||
f.account_prefix.value=d.set?d.prefix:'';
|
||||
f.account_prefix.placeholder=d.prefix;
|
||||
if(d.error){ out.innerHTML='<span style="color:var(--warn)">учёт недоступен: '+esc(d.error)+'</span>'; return; }
|
||||
if(d.count===undefined){ out.textContent='сейчас: '+d.prefix+'…'; return; }
|
||||
out.innerHTML=d.count>0?'<span style="color:var(--ok)">✓ «'+esc(d.prefix)+'…» — открытых счетов: '+d.count+'</span>':'<span style="color:var(--err)">✗ открытых счетов «'+esc(d.prefix)+'…» в учёте нет — выберите префикс ниже</span>';
|
||||
var c=d.candidates||[];
|
||||
dl.innerHTML=c.map(function(x){return '<option value="'+esc(x.prefix)+'">'}).join('');
|
||||
cl.innerHTML=c.length?('Префиксы открытых счетов в учёте: '+c.map(function(x){return '<a href="#" data-p="'+esc(x.prefix)+'"><code>'+esc(x.prefix)+'</code></a> — '+x.count}).join(', ')):'';
|
||||
cl.querySelectorAll('a[data-p]').forEach(function(a){ a.onclick=function(e){ e.preventDefault(); f.account_prefix.value=a.dataset.p; f.account_prefix.focus(); }; });
|
||||
}
|
||||
function load(body){ out.textContent=body?'сохраняю…':'проверяю учёт…';
|
||||
fetch('/admin/setup/fancy/acc-prefix',{method:body?'POST':'GET',body:body,cache:'no-store'}).then(function(r){return r.json()}).then(show).catch(function(){ out.textContent='не удалось проверить'; }); }
|
||||
box.querySelector('.acc-save').onclick=function(){ var p=new URLSearchParams(); p.set('account_prefix',f.account_prefix.value.trim()); load(p); };
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -26,6 +26,7 @@
|
||||
</select></label>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button><button type="submit" formaction="/admin/setup/fancy/test" class="btn">Проверить соединение</button></div>
|
||||
</form>
|
||||
{{if .Settings.Fancy.Server}}{{template "accPrefix" .}}{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="sec-fancy-depo-tables">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{{define "igateInstall"}}{{end}}
|
||||
Reference in New Issue
Block a user