diff --git a/deploy/linux/sbin/bj-keymedia-worker b/deploy/linux/sbin/bj-keymedia-worker
index 32746b7..964bbe5 100755
--- a/deploy/linux/sbin/bj-keymedia-worker
+++ b/deploy/linux/sbin/bj-keymedia-worker
@@ -17,6 +17,8 @@ process_one(){
rng-status|rng-start|rng-cancel) out=$(/usr/local/sbin/bj-validata-rng "${action#rng-}" 2>&1) ;;
# системные настройки сервера: серверы времени (NTP)
system-ntp|system-tz|system-sync|system-ports) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-system-admin "${action#system-}" 2>&1) ;;
+ # вход 1С в базу bj (проверка поручений в 1С): логин onec_bj, pg_hba.conf; параметры — JSON в extra
+ onec-access|onec-revoke) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-onec-admin "${action#onec-}" 2>&1) ;;
# резервное копирование и восстановление (настройки bj → bj-backup-admin); параметры — JSON в extra
backup-config|backup-test|backup-run|backup-list|backup-verify|backup-restore) out=$(BJ_EXTRA="$extra" /usr/local/sbin/bj-backup-admin "${action#backup-}" 2>&1) ;;
# Перезапуск службы модуля из админки. Список закрытый: чужую службу
diff --git a/deploy/linux/sbin/bj-onec-admin b/deploy/linux/sbin/bj-onec-admin
new file mode 100755
index 0000000..e07a535
--- /dev/null
+++ b/deploy/linux/sbin/bj-onec-admin
@@ -0,0 +1,104 @@
+#!/bin/bash
+# bj-onec-admin — вход 1С в базу PostgreSQL bj для проверки поручений в 1С (через root-демон bj-keymedia).
+# access — логин onec_bj с паролем, вход в базу bj только с IP сервера 1С и только по TLS:
+# свои строки в начале pg_hba.conf (# bj-onec), PostgreSQL слушает адрес сервера bj,
+# с которого виден сервер 1С (перезапуск PostgreSQL — только если этот адрес не слушался),
+# порт 5432 в ufw для IP 1С, если ufw включён. BJ_EXTRA: {"password":"…","ips":"10.0.0.5/32,…"}
+# revoke — закрыть вход: NOLOGIN, строки # bj-onec из pg_hba.conf и правила ufw убрать
+# Права на объекты onec.* выдаёт сам bj (он их владелец).
+set -uo pipefail
+q() { python3 -c "import json,sys;print(json.dumps(sys.argv[1],ensure_ascii=False))" "$1"; }
+ok() { echo "{\"ok\":true,\"output\":$(q "$1")${2:-}}"; exit 0; }
+err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; }
+x() { python3 -c "import json,os,sys;print(json.loads(os.environ.get('BJ_EXTRA') or '{}').get(sys.argv[1],''))" "$1" 2>/dev/null; }
+cd /
+psqlq() { runuser -u postgres -- psql -X -q -At -v ON_ERROR_STOP=1 "$@"; }
+LOGIN=onec_bj
+DB=bj
+MARK='# bj-onec'
+
+hba=$(psqlq -d postgres -c 'show hba_file' 2>&1) || err "PostgreSQL недоступен: $hba"
+[ -f "$hba" ] || err "не найден pg_hba.conf ($hba)"
+
+# hba_set "строки" — заменить наш блок (пусто — убрать) в начале pg_hba.conf
+hba_set() {
+ [ -f "$hba.bak-bj-onec" ] || cp -a "$hba" "$hba.bak-bj-onec"
+ python3 - "$hba" "$MARK" "$1" <<'PY' || return 1
+import sys
+p, mark, new = sys.argv[1], sys.argv[2], sys.argv[3]
+old = [l for l in open(p).read().split("\n") if not l.rstrip().endswith(mark)]
+add = [l for l in new.split("\n") if l.strip()]
+open(p, "w").write("\n".join(add + old).rstrip("\n") + "\n")
+PY
+}
+
+case "${1:-}" in
+ access)
+ pw=$(x password); ips=$(x ips)
+ [[ "$pw" =~ ^[A-Za-z0-9]{20,64}$ ]] || err "пароль: 20–64 латинских букв и цифр"
+ list=()
+ for ip in ${ips//,/ }; do
+ [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/(2[4-9]|3[0-2])$ ]] || err "недопустимый адрес 1С: $ip"
+ list+=("$ip")
+ done
+ [ ${#list[@]} -gt 0 ] || err "не указан IP-адрес сервера 1С"
+ [ "$(psqlq -d postgres -c 'show ssl')" = on ] || err "в PostgreSQL выключено шифрование (ssl = off) — 1С подключается только по TLS"
+ psqlq -d postgres -c "select 1 from pg_database where datname='$DB'" | grep -q 1 || err "нет базы $DB"
+ # логин: только вход, без прав на создание чего-либо; пароль — через переменную psql (не в командной строке)
+ printf '%s\n' \
+ "DO \$\$BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$LOGIN') THEN CREATE ROLE $LOGIN; END IF; END\$\$;" \
+ "ALTER ROLE $LOGIN WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 5 PASSWORD :'pw';" \
+ "ALTER ROLE $LOGIN SET statement_timeout = '30s';" \
+ "ALTER ROLE $LOGIN SET search_path = onec;" \
+ | psqlq -d postgres -v pw="$pw" >/dev/null 2>&1 || err "логин $LOGIN не заведён"
+ # в схеме public базы bj создавать объекты может только bj (у PUBLIC право снимаем)
+ psqlq -d "$DB" -c "GRANT USAGE, CREATE ON SCHEMA public TO bj" -c "REVOKE CREATE ON SCHEMA public FROM PUBLIC" >/dev/null 2>&1 \
+ || err "права на схему public в базе $DB не изменены"
+ lines=""
+ for ip in "${list[@]}"; do lines+="hostssl $DB $LOGIN $ip md5 $MARK"$'\n'; done
+ lines+="host all $LOGIN 0.0.0.0/0 reject $MARK"$'\n'"host all $LOGIN ::/0 reject $MARK"
+ hba_set "$lines" || err "не удалось записать $hba"
+ # адрес сервера bj, с которого виден сервер 1С
+ first=${list[0]%/*}
+ src=$(ip -o route get "$first" 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -1)
+ [ -n "$src" ] || err "нет маршрута до сервера 1С ($first)"
+ cur=$(psqlq -d postgres -c 'show listen_addresses')
+ restart=no
+ case ",${cur// /}," in
+ *,\*,*|*,"$src",*|*,0.0.0.0,*) ;;
+ *) new="${cur// /},$src"
+ printf '%s\n' "ALTER SYSTEM SET listen_addresses = :'v';" | psqlq -d postgres -v v="$new" >/dev/null 2>&1 || err "listen_addresses не изменён"
+ restart=yes ;;
+ esac
+ if [ "$restart" = yes ]; then
+ systemctl restart postgresql >/dev/null 2>&1 || err "PostgreSQL не перезапустился (listen_addresses = $new)"
+ for i in $(seq 1 30); do psqlq -d postgres -c 'select 1' >/dev/null 2>&1 && break; sleep 1; done
+ else
+ psqlq -d postgres -c 'select pg_reload_conf()' >/dev/null 2>&1 || err "PostgreSQL не перечитал pg_hba.conf"
+ fi
+ fw=""
+ if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
+ for ip in "${list[@]}"; do ufw allow from "$ip" to any port 5432 proto tcp comment bj-onec >/dev/null 2>&1; done
+ fw="; порт 5432 открыт в ufw"
+ fi
+ port=$(psqlq -d postgres -c 'show port')
+ note="логин $LOGIN, вход с ${list[*]} по TLS, сервер $src:$port"
+ [ "$restart" = yes ] && note+="; PostgreSQL теперь слушает $src (перезапущен)"
+ ok "$note$fw" ",\"host\":$(q "$src"),\"port\":$(q "$port")"
+ ;;
+ revoke)
+ psqlq -d postgres -c "DO \$\$BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$LOGIN') THEN ALTER ROLE $LOGIN NOLOGIN; END IF; END\$\$;" >/dev/null 2>&1 \
+ || err "вход логином $LOGIN не закрыт"
+ # открытые сеансы 1С — завершить
+ psqlq -d postgres -c "select pg_terminate_backend(pid) from pg_stat_activity where usename = '$LOGIN'" >/dev/null 2>&1
+ hba_set "" || err "не удалось записать $hba"
+ psqlq -d postgres -c 'select pg_reload_conf()' >/dev/null 2>&1
+ if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then
+ while n=$(ufw status numbered 2>/dev/null | grep 'bj-onec' | head -1 | sed -n 's/^\[ *\([0-9]*\)\].*/\1/p') && [ -n "$n" ]; do
+ ufw --force delete "$n" >/dev/null 2>&1 || break
+ done
+ fi
+ ok "вход логином $LOGIN закрыт"
+ ;;
+ *) err "команда: access | revoke" ;;
+esac
diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go
index 064da01..025f71e 100644
--- a/internal/lkgateway/admin.go
+++ b/internal/lkgateway/admin.go
@@ -565,6 +565,7 @@ type claimData struct {
page
Claim ClaimView
Quik []*QuikLink
+ OneC []OneCRow // проверка поручений в 1С (onec_check.go)
Watch *OrderWatch
DepoL *DepoLink
Depo *OutgoingDeal // депозитарное поручение 16 (списание) по этой сделке, если черновик пришёл
@@ -914,8 +915,12 @@ func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service
}
}
qfISIN, qfReply := quikRegisterFailure(quik)
+ var onec []OneCRow
+ if globalRC != nil && view.M2MGUID != "" {
+ onec = onecRowsFor(r.Context(), globalRC, string(view.M2MGUID))
+ }
render(w, a.claim, claimData{
- Quik: quik, Watch: watch, DepoL: depoL, Fail: fail,
+ Quik: quik, Watch: watch, DepoL: depoL, Fail: fail, OneC: onec,
page: nowPage("Заявка", "claims"),
Claim: view, Depo: depo, SubstAs: substAs, Flash: r.URL.Query().Get("flash"),
SentToNSD: claimSentToNSD(view), QuikFailISIN: qfISIN, QuikFailReply: qfReply,
diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go
index 57c4055..4637ad0 100644
--- a/internal/lkgateway/auth.go
+++ b/internal/lkgateway/auth.go
@@ -1076,7 +1076,7 @@ func moduleForPath(p string) string {
return "web-cabinet"
case p == "/admin/setup/lk", has("/admin/setup/lk-check"), has("/admin/setup/lk-integration"), has("/admin/setup/lk-pull"):
return "esia-finance" // интеграция с платформой ЕСИА Финанс — отдельный модуль лицензии
- case has("/admin/setup/onec"):
+ case has("/admin/setup/onec"), has("/admin/onec"):
return "onec-back"
}
return ""
diff --git a/internal/lkgateway/lktransfer.go b/internal/lkgateway/lktransfer.go
index 1e404b2..d037819 100644
--- a/internal/lkgateway/lktransfer.go
+++ b/internal/lkgateway/lktransfer.go
@@ -248,7 +248,8 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
if emu, _ := lkEmulateNSD(rc); emu && lkEmuSender != nil {
sender = lkEmuSender
}
- if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil {
+ resp, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil)
+ if err != nil {
var nd *igw.NotDeliveredError
if errors.As(err, &nd) {
// НРД сейчас не принимает пакеты: заявка сохранена и уйдёт сама, когда связь восстановится
@@ -262,6 +263,13 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
_ = lkMarkStatus(ctx, rc, id, "error")
return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err)
}
+ if resp.Status == onecWaitStatus { // проверка поручения в 1С (onec_check.go)
+ _ = lkMarkStatus(ctx, rc, id, "checking")
+ return LKTransferResult{
+ OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "checking",
+ Message: "Заявка принята и проходит проверку. После неё будет отправлена в НРД. Итог появится в разделе «Мои заявки».",
+ }, nil
+ }
_ = lkMarkStatus(ctx, rc, id, "sent")
return LKTransferResult{
OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "sent",
diff --git a/internal/lkgateway/onec.go b/internal/lkgateway/onec.go
index 5262bc4..4d5554c 100644
--- a/internal/lkgateway/onec.go
+++ b/internal/lkgateway/onec.go
@@ -23,6 +23,8 @@ type OneCSettings struct {
CheckedAt time.Time `json:"checked_at,omitempty"`
CheckOK bool `json:"check_ok,omitempty"`
CheckMsg string `json:"check_msg,omitempty"`
+ // Check — проверка поручений МНП в 1С перед отправкой в НРД (onec_check.go).
+ Check OneCCheckSettings `json:"check,omitempty"`
}
func (r *RuntimeConfig) UpdateOneC(s OneCSettings) error {
diff --git a/internal/lkgateway/onec_check.go b/internal/lkgateway/onec_check.go
new file mode 100644
index 0000000..74005b3
--- /dev/null
+++ b/internal/lkgateway/onec_check.go
@@ -0,0 +1,1157 @@
+package lkgateway
+
+// Проверка поручений в 1С (22.10.2026) — часть модуля лицензии onec-back, включается отдельно в
+// настройках («Проверка поручений в 1С»). Исходящий перевод уходит в НРД только после того, как 1С
+// подтвердила каждое поручение МНП («Вывод ЦБ (M2M)») этого перевода. Обмен — через базу PostgreSQL bj:
+// - bj кладёт поручения в onec.instr (ключ — номер поручения МНП) и держит перевод «Не отправлена»;
+// - 1С читает представление onec.instr_queue и записывает решение функцией onec.set_decision под
+// своим логином onec_bj (логин, вход по IP и TLS заводит root-скрипт bj-onec-admin);
+// - onecLoop по решению отправляет перевод в НРД или снимает поручения в МНП с причиной из 1С.
+// Пока проверка выключена (или модуля нет в лицензии), перевод идёт как раньше, объекты для 1С не
+// создаются, вход onec_bj закрыт. Инструкция программисту 1С — документ «Bridge&Join × 1С: проверка
+// поручений МНП».
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "math/big"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
+)
+
+// OneCCheckSettings — проверка поручений в 1С.
+type OneCCheckSettings struct {
+ Enabled bool `json:"enabled,omitempty"`
+ EnabledAt time.Time `json:"enabled_at,omitempty"` // когда включили (поручения из терминала — с этого момента)
+ ClientIPs string `json:"client_ips,omitempty"` // IP сервера 1С через запятую: вход в базу bj только с них
+ TimeoutMin int `json:"timeout_min,omitempty"` // срок решения 1С, мин (0 — 30): дольше — оповещение оператору
+ AccessAt time.Time `json:"access_at,omitempty"` // когда bj последний раз открыл вход логину onec_bj
+ AccessOK bool `json:"access_ok,omitempty"`
+ AccessMsg string `json:"access_msg,omitempty"`
+}
+
+// Timeout — срок решения 1С.
+func (s OneCCheckSettings) Timeout() time.Duration {
+ if s.TimeoutMin <= 0 {
+ return 30 * time.Minute
+ }
+ return time.Duration(s.TimeoutMin) * time.Minute
+}
+
+const (
+ onecWaitStatus = "onec_wait" // ответ SendBuiltRequestVia: перевод ждёт решения 1С
+ onecLostAfter = 15 * time.Minute // 1С не читала очередь дольше — связь с 1С потеряна
+ onecLogin = "onec_bj" // логин 1С в PostgreSQL bj
+ onecKeepDays = 45 // сверка bj_state — по строкам не старше
+ onecTermPrefix = "terminal-" // поручения, поданные клиентом из терминала QUIK (quikhooks.go)
+ onecPwLen = 24 // длина пароля onec_bj
+ onecPwAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
+)
+
+// onecCheckActive — проверка 1С включена, модуль в лицензии, база bj настроена.
+func onecCheckActive(rc *RuntimeConfig) bool {
+ if rc == nil {
+ return false
+ }
+ s := rc.Snapshot()
+ return s.OneC.Check.Enabled && strings.TrimSpace(s.Postgres.DSN) != "" && moduleAllowed(rc, "onec-back")
+}
+
+// onecSchemaDDL — объекты для 1С в базе bj (владелец — bj). Совместимо с PostgreSQL 11.
+var onecSchemaDDL = []string{
+ `CREATE SCHEMA IF NOT EXISTS onec`,
+ `CREATE TABLE IF NOT EXISTS onec.instr (
+ id bigserial PRIMARY KEY,
+ guid text NOT NULL,
+ isin text NOT NULL DEFAULT '',
+ instr_no bigint,
+ source text NOT NULL DEFAULT 'lk',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ direction text NOT NULL DEFAULT 'OUT',
+ client_code text,
+ contract text,
+ sec_code text,
+ qty numeric(20,7),
+ depo_account text,
+ counterparty_code text,
+ counterparty_name text,
+ status text NOT NULL DEFAULT 'NEW' CHECK (status IN ('NEW','CONFIRMED','REJECTED','CANCELLED')),
+ reason text,
+ checked_by text,
+ checked_at timestamptz,
+ onec_doc text,
+ by_operator boolean NOT NULL DEFAULT false,
+ bj_state text NOT NULL DEFAULT 'WAIT_1C',
+ bj_state_at timestamptz NOT NULL DEFAULT now(),
+ bj_note text,
+ alerted_at timestamptz
+ )`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS instr_no_uq ON onec.instr (instr_no) WHERE instr_no IS NOT NULL`,
+ `CREATE UNIQUE INDEX IF NOT EXISTS instr_nomnp_uq ON onec.instr (guid, isin) WHERE instr_no IS NULL`,
+ `CREATE INDEX IF NOT EXISTS instr_guid ON onec.instr (guid)`,
+ `CREATE TABLE IF NOT EXISTS onec.reads (
+ id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
+ read_at timestamptz NOT NULL,
+ reads bigint NOT NULL DEFAULT 0
+ )`,
+ // Очередь для 1С: чтение отмечает время (по нему bj видит, что 1С на связи).
+ `CREATE OR REPLACE FUNCTION onec.queue() RETURNS TABLE (
+ instr_no bigint, created_at timestamptz, direction text, client_code text, contract text,
+ isin text, sec_code text, qty numeric, depo_account text, counterparty_code text,
+ counterparty_name text, status text, reason text, checked_by text, checked_at timestamptz,
+ onec_doc text, bj_state text, bj_state_at timestamptz)
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $f$
+ BEGIN
+ INSERT INTO onec.reads AS r (id, read_at, reads) VALUES (1, now(), 1)
+ ON CONFLICT (id) DO UPDATE SET read_at = now(), reads = r.reads + 1;
+ RETURN QUERY SELECT i.instr_no, i.created_at, i.direction, i.client_code, i.contract,
+ i.isin, i.sec_code, i.qty::numeric, i.depo_account, i.counterparty_code,
+ i.counterparty_name, i.status, i.reason, i.checked_by, i.checked_at,
+ i.onec_doc, CASE WHEN i.bj_state = 'WAIT_OPER' THEN 'WAIT_1C' ELSE i.bj_state END, i.bj_state_at
+ FROM onec.instr i WHERE i.instr_no IS NOT NULL ORDER BY i.created_at, i.id;
+ END
+ $f$`,
+ `CREATE OR REPLACE VIEW onec.instr_queue AS SELECT * FROM onec.queue()`,
+ // Решение 1С: окончательное; повтор того же решения — ALREADY, другое — CONFLICT.
+ `CREATE OR REPLACE FUNCTION onec.set_decision(p_instr_no bigint, p_status text, p_reason text,
+ p_checked_by text, p_onec_doc text) RETURNS text
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $f$
+ DECLARE
+ st text := upper(btrim(coalesce(p_status, '')));
+ rs text := nullif(btrim(coalesce(p_reason, '')), '');
+ cur onec.instr%ROWTYPE;
+ BEGIN
+ IF st NOT IN ('CONFIRMED', 'REJECTED') THEN
+ RETURN 'ERROR: статус — CONFIRMED или REJECTED';
+ END IF;
+ IF st = 'REJECTED' AND rs IS NULL THEN
+ RETURN 'ERROR: при отказе нужна причина';
+ END IF;
+ IF char_length(rs) > 255 THEN
+ RETURN 'ERROR: причина длиннее 255 символов';
+ END IF;
+ SELECT * INTO cur FROM onec.instr WHERE instr_no = p_instr_no FOR UPDATE;
+ IF NOT FOUND THEN
+ RETURN 'NOT_FOUND';
+ END IF;
+ IF cur.status <> 'NEW' THEN
+ IF cur.status = st THEN
+ RETURN 'ALREADY';
+ END IF;
+ RETURN 'CONFLICT';
+ END IF;
+ UPDATE onec.instr SET status = st,
+ reason = CASE WHEN st = 'REJECTED' THEN rs END,
+ checked_by = left(nullif(btrim(coalesce(p_checked_by, '')), ''), 200),
+ checked_at = now(),
+ onec_doc = left(nullif(btrim(coalesce(p_onec_doc, '')), ''), 500)
+ WHERE id = cur.id;
+ RETURN 'OK';
+ END
+ $f$`,
+ `REVOKE ALL ON SCHEMA onec FROM PUBLIC`,
+ `REVOKE ALL ON ALL TABLES IN SCHEMA onec FROM PUBLIC`,
+ `REVOKE ALL ON FUNCTION onec.queue() FROM PUBLIC`,
+ `REVOKE ALL ON FUNCTION onec.set_decision(bigint, text, text, text, text) FROM PUBLIC`,
+}
+
+// onecGrantSQL — права логину 1С (выдаёт bj как владелец объектов, когда логин заведён).
+var onecGrantSQL = []string{
+ `GRANT USAGE ON SCHEMA onec TO ` + onecLogin,
+ `GRANT SELECT ON onec.instr_queue TO ` + onecLogin,
+ `GRANT EXECUTE ON FUNCTION onec.queue(), onec.set_decision(bigint, text, text, text, text) TO ` + onecLogin,
+}
+
+var (
+ onecMu sync.Mutex
+ onecSchemaAt string // DSN, в котором объекты для 1С созданы этим процессом
+ onecGranted bool // права логину onec_bj выданы
+)
+
+// onecDB — база bj с объектами для 1С (создаёт их при первом обращении).
+func onecDB(ctx context.Context, rc *RuntimeConfig) (*sql.DB, error) {
+ db, err := lkDB(rc)
+ if err != nil {
+ return nil, err
+ }
+ onecMu.Lock()
+ defer onecMu.Unlock()
+ dsn := rc.Snapshot().Postgres.DSN
+ cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ if onecSchemaAt != dsn {
+ for _, q := range onecSchemaDDL {
+ if _, err := db.ExecContext(cctx, q); err != nil {
+ return nil, fmt.Errorf("объекты для 1С в базе bj: %w", err)
+ }
+ }
+ onecSchemaAt, onecGranted = dsn, false
+ }
+ if !onecGranted {
+ var n int
+ if err := db.QueryRowContext(cctx, `SELECT count(*) FROM pg_roles WHERE rolname = $1`, onecLogin).Scan(&n); err != nil {
+ return nil, fmt.Errorf("логин 1С в базе bj: %w", err)
+ }
+ if n > 0 {
+ for _, q := range onecGrantSQL {
+ if _, err := db.ExecContext(cctx, q); err != nil {
+ return nil, fmt.Errorf("права логину %s: %w", onecLogin, err)
+ }
+ }
+ onecGranted = true
+ }
+ }
+ return db, nil
+}
+
+// onecResetGrants — логин 1С заведён заново: выдать права при следующем обращении.
+func onecResetGrants() {
+ onecMu.Lock()
+ onecGranted = false
+ onecMu.Unlock()
+}
+
+// ---- проверка перед отправкой в НРД ----
+
+// onecItem — бумага перевода и её поручение МНП.
+type onecItem struct {
+ ISIN, SecCode, DepoAccount, Client, Contract string
+ Qty float64
+ InstrNo int64 // 0 — поручения МНП нет
+}
+
+// onecInstrNo — номер поручения МНП числом (0 — нет).
+func onecInstrNo(s string) int64 {
+ s = strings.TrimSpace(s)
+ if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
+ return n
+ }
+ if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 1 && f == float64(int64(f)) {
+ return int64(f)
+ }
+ return 0
+}
+
+func onecItemsFor(req *m2m.M2MTransferRequest) []onecItem {
+ guid := string(req.Header.GUID)
+ var out []onecItem
+ for _, sec := range req.Data.TransferredSecurities.Securities {
+ it := onecItem{ISIN: quikSecISIN(sec), SecCode: string(sec.SecurityCode), Qty: quikSecQty(sec.Quantity)}
+ if len(sec.SettlementAccount) > 0 {
+ it.DepoAccount = string(sec.SettlementAccount[0].SettlementLocation.AccountID)
+ }
+ if l := globalQuikLinks.Get(guid, it.ISIN); l != nil {
+ it.Client, it.Contract = l.ClientID, l.Contract
+ if l.State == "registered" || l.State == "signed" {
+ it.InstrNo = onecInstrNo(l.InstrNo)
+ }
+ }
+ out = append(out, it)
+ }
+ return out
+}
+
+// onecCounterparty — депозитарий получателя: код участника НРД и наименование по справочнику.
+func onecCounterparty(req *m2m.M2MTransferRequest) (code, name string) {
+ inn := strings.TrimSpace(string(req.Data.ReceivingDepository.INN))
+ if e, ok := handbookByINN(inn); ok {
+ return pick(e.Code, inn), pick(e.DisplayName, e.Name)
+ }
+ return inn, ""
+}
+
+func onecNull(s string) any {
+ if s = strings.TrimSpace(s); s == "" {
+ return nil
+ }
+ return s
+}
+
+// onecUpsert — поручения перевода в очередь 1С. Поручение, уже стоящее в очереди (подано клиентом
+// из терминала и привязано к заявке), переходит к этому переводу вместе с решением 1С.
+func onecUpsert(ctx context.Context, db *sql.DB, req *m2m.M2MTransferRequest) error {
+ guid := string(req.Header.GUID)
+ cpCode, cpName := onecCounterparty(req)
+ for _, it := range onecItemsFor(req) {
+ args := []any{guid, it.ISIN, onecNull(it.Client), onecNull(it.Contract), onecNull(it.SecCode), it.Qty, onecNull(it.DepoAccount), onecNull(cpCode), onecNull(cpName)}
+ if it.InstrNo == 0 {
+ if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr (guid, isin, client_code, contract, sec_code, qty, depo_account, counterparty_code, counterparty_name, bj_state, bj_note)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'WAIT_OPER', 'нет поручения МНП — 1С проверить нечего, решает оператор bj')
+ ON CONFLICT (guid, isin) WHERE instr_no IS NULL DO NOTHING`, args...); err != nil {
+ return err
+ }
+ continue
+ }
+ if _, err := db.ExecContext(ctx, `DELETE FROM onec.instr WHERE guid = $1 AND isin = $2 AND instr_no IS NULL AND status = 'NEW'`, guid, it.ISIN); err != nil {
+ return err
+ }
+ if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr AS t (guid, isin, client_code, contract, sec_code, qty, depo_account, counterparty_code, counterparty_name, instr_no)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (instr_no) WHERE instr_no IS NOT NULL DO UPDATE SET guid = EXCLUDED.guid, isin = EXCLUDED.isin,
+ client_code = COALESCE(EXCLUDED.client_code, t.client_code), contract = COALESCE(EXCLUDED.contract, t.contract),
+ sec_code = COALESCE(EXCLUDED.sec_code, t.sec_code), depo_account = COALESCE(EXCLUDED.depo_account, t.depo_account),
+ counterparty_code = COALESCE(EXCLUDED.counterparty_code, t.counterparty_code),
+ counterparty_name = COALESCE(EXCLUDED.counterparty_name, t.counterparty_name)`, append(args, it.InstrNo)...); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// onecVerdict — сводное решение по строкам перевода: wait (ждём), reject (отказ по любой бумаге)
+// или pass (все поручения подтверждены).
+func onecVerdict(statuses []string) string {
+ if len(statuses) == 0 {
+ return "wait"
+ }
+ all := true
+ for _, st := range statuses {
+ switch st {
+ case "REJECTED", "CANCELLED":
+ return "reject"
+ case "CONFIRMED":
+ default:
+ all = false
+ }
+ }
+ if all {
+ return "pass"
+ }
+ return "wait"
+}
+
+// onecRequestStatuses — статусы строк очереди по бумагам перевода.
+func onecRequestStatuses(ctx context.Context, db *sql.DB, req *m2m.M2MTransferRequest) ([]string, error) {
+ guid := string(req.Header.GUID)
+ var out []string
+ for _, it := range onecItemsFor(req) {
+ var st string
+ var err error
+ if it.InstrNo == 0 {
+ err = db.QueryRowContext(ctx, `SELECT status FROM onec.instr WHERE guid = $1 AND isin = $2 AND instr_no IS NULL`, guid, it.ISIN).Scan(&st)
+ } else {
+ err = db.QueryRowContext(ctx, `SELECT status FROM onec.instr WHERE instr_no = $1`, it.InstrNo).Scan(&st)
+ }
+ if errors.Is(err, sql.ErrNoRows) {
+ st, err = "NEW", nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, st)
+ }
+ return out, nil
+}
+
+// onecGate — хук перед отправкой в НРД (service.go): true — перевод ждёт решения 1С (в НРД не
+// отправлять). Ручной ввод с блокировкой бумаг сотрудником не проверяется: решение принял оператор.
+func onecGate(ctx context.Context, rc *RuntimeConfig, req *m2m.M2MTransferRequest) (bool, error) {
+ if req == nil || !onecCheckActive(rc) || manualQuikSkipped(ctx) {
+ return false, nil
+ }
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ return false, fmt.Errorf("проверка 1С: %w", err)
+ }
+ cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ if err := onecUpsert(cctx, db, req); err != nil {
+ return false, fmt.Errorf("проверка 1С: очередь: %w", err)
+ }
+ sts, err := onecRequestStatuses(cctx, db, req)
+ if err != nil {
+ return false, fmt.Errorf("проверка 1С: %w", err)
+ }
+ guid := string(req.Header.GUID)
+ switch onecVerdict(sts) {
+ case "pass":
+ _, _ = db.ExecContext(cctx, `UPDATE onec.instr SET bj_state = 'WAIT_SEND', bj_state_at = now() WHERE guid = $1 AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`, guid)
+ log.Printf("onec: %s подтверждён 1С — отправка в НРД", shortGUID(guid))
+ return false, nil
+ case "reject":
+ onecKickNow() // отказ обработает onecLoop
+ }
+ globalLKFlows.stepIfExists(guid, "onec", "Проверка 1С: ждём решения", "Поручения МНП переданы на проверку в 1С. Перевод уйдёт в НРД после подтверждения.")
+ log.Printf("onec: %s ждёт решения 1С", shortGUID(guid))
+ return true, nil
+}
+
+// ---- обработка решений ----
+
+var onecKick = make(chan struct{}, 1)
+
+// onecKickNow — разбудить onecLoop (решение оператора, отказ).
+func onecKickNow() {
+ select {
+ case onecKick <- struct{}{}:
+ default:
+ }
+}
+
+// onecAlerted — оповещения, которые уже ушли (повтор — только после восстановления).
+var onecAlerted = struct {
+ sync.Mutex
+ m map[string]bool
+}{m: map[string]bool{}}
+
+func onecAlertOnce(rc *RuntimeConfig, key, title, body string) {
+ onecAlerted.Lock()
+ seen := onecAlerted.m[key]
+ onecAlerted.m[key] = true
+ onecAlerted.Unlock()
+ if !seen {
+ raiseSystemAlert(rc, title, body)
+ }
+}
+
+func onecAlertClear(key string) {
+ onecAlerted.Lock()
+ delete(onecAlerted.m, key)
+ onecAlerted.Unlock()
+}
+
+// onecLoop — решения 1С: отправка в НРД, снятие в МНП, сроки, связь с 1С. Раз в минуту и по сигналу.
+func (s *Server) onecLoop(ctx context.Context) {
+ t := time.NewTicker(time.Minute)
+ defer t.Stop()
+ for {
+ if onecCheckActive(s.rc) {
+ onecTick(ctx, s.rc, s.svc, time.Now())
+ }
+ healthBeat("onec-check")
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ case <-onecKick:
+ }
+ }
+}
+
+// OneCRow — строка очереди 1С (для обработки и показа).
+type OneCRow struct {
+ ID int64
+ GUID, ISIN, Source, Client, SecCode string
+ InstrNo int64
+ Qty float64
+ Status, Reason, CheckedBy, OneCDoc string
+ BJState, Note string
+ ByOperator bool
+ CreatedAt, CheckedAt, BJStateAt, Alerted time.Time
+}
+
+const onecRowCols = `id, guid, isin, source, COALESCE(client_code, ''), COALESCE(sec_code, ''), COALESCE(instr_no, 0), COALESCE(qty, 0)::float8,
+ status, COALESCE(reason, ''), COALESCE(checked_by, ''), COALESCE(onec_doc, ''), bj_state, COALESCE(bj_note, ''), by_operator,
+ created_at, checked_at, bj_state_at, alerted_at`
+
+func onecScan(rows *sql.Rows) ([]OneCRow, error) {
+ defer rows.Close()
+ var out []OneCRow
+ for rows.Next() {
+ var r OneCRow
+ var ch, al sql.NullTime
+ if err := rows.Scan(&r.ID, &r.GUID, &r.ISIN, &r.Source, &r.Client, &r.SecCode, &r.InstrNo, &r.Qty,
+ &r.Status, &r.Reason, &r.CheckedBy, &r.OneCDoc, &r.BJState, &r.Note, &r.ByOperator,
+ &r.CreatedAt, &ch, &r.BJStateAt, &al); err != nil {
+ return out, err
+ }
+ r.CheckedAt, r.Alerted = ch.Time, al.Time
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+func onecQuery(ctx context.Context, db *sql.DB, where string, args ...any) ([]OneCRow, error) {
+ rows, err := db.QueryContext(ctx, `SELECT `+onecRowCols+` FROM onec.instr `+where, args...)
+ if err != nil {
+ return nil, err
+ }
+ return onecScan(rows)
+}
+
+func onecSetState(ctx context.Context, db *sql.DB, id int64, state, note string) {
+ if _, err := db.ExecContext(ctx, `UPDATE onec.instr SET bj_state = $2, bj_state_at = now(), bj_note = COALESCE($3, bj_note) WHERE id = $1 AND bj_state <> $2`, id, state, onecNull(note)); err != nil {
+ log.Printf("onec: строка %d → %s: %v", id, state, err)
+ }
+}
+
+func onecTick(ctx context.Context, rc *RuntimeConfig, svc *Service, now time.Time) {
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ onecAlertOnce(rc, "db", "Проверка 1С: база bj недоступна", "Проверка поручений в 1С не работает: "+err.Error()+". Переводы ждут, в НРД не уходят.")
+ return
+ }
+ onecAlertClear("db")
+ cctx, cancel := context.WithTimeout(ctx, 4*time.Minute)
+ defer cancel()
+ onecEnqueueTerminal(cctx, rc, db)
+ onecRefreshNoMNP(cctx, svc, db)
+ onecCheckCancelled(cctx, rc, db)
+ onecApply(cctx, rc, svc, db)
+ onecSync(cctx, svc, db)
+ onecWatch(cctx, rc, db, now)
+}
+
+// onecEnqueueTerminal — поручения «Вывод ЦБ (M2M)», поданные клиентом из терминала (quikhooks.go):
+// тоже на проверку 1С. Перевод по ним оформляет оператор; отказ 1С снимает поручение в МНП.
+func onecEnqueueTerminal(ctx context.Context, rc *RuntimeConfig, db *sql.DB) {
+ since := rc.Snapshot().OneC.Check.EnabledAt
+ for _, l := range globalQuikLinks.List(0) {
+ if !l.IsTerminal() || l.State != "manual" || l.Direction != "out" || l.UpdatedAt.Before(since) {
+ continue
+ }
+ no := onecInstrNo(l.InstrNo)
+ if no == 0 {
+ continue
+ }
+ if _, err := db.ExecContext(ctx, `INSERT INTO onec.instr (guid, isin, instr_no, source, client_code, contract, qty)
+ VALUES ($1, $2, $3, 'terminal', $4, $5, $6) ON CONFLICT (instr_no) WHERE instr_no IS NOT NULL DO NOTHING`,
+ l.GUID, l.ISIN, no, onecNull(l.ClientID), onecNull(l.Contract), l.Qty); err != nil {
+ log.Printf("onec: поручение из терминала №%d: %v", no, err)
+ }
+ }
+}
+
+// onecRefreshNoMNP — перевод ждал без поручения МНП, а оператор зарегистрировал или привязал его:
+// теперь проверяет 1С.
+func onecRefreshNoMNP(ctx context.Context, svc *Service, db *sql.DB) {
+ rows, err := onecQuery(ctx, db, `WHERE instr_no IS NULL AND status = 'NEW' AND bj_state = 'WAIT_OPER'`)
+ if err != nil {
+ return
+ }
+ done := map[string]bool{}
+ for _, r := range rows {
+ if done[r.GUID] {
+ continue
+ }
+ done[r.GUID] = true
+ l := globalQuikLinks.Get(r.GUID, r.ISIN)
+ if l == nil || onecInstrNo(l.InstrNo) == 0 || (l.State != "registered" && l.State != "signed") {
+ continue
+ }
+ if d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(r.GUID)); err == nil && d != nil && d.Request != nil {
+ if err := onecUpsert(ctx, db, d.Request); err != nil {
+ log.Printf("onec: %s: %v", shortGUID(r.GUID), err)
+ }
+ }
+ }
+}
+
+// onecCheckCancelled — клиент снял поручение в QUIK до решения 1С: решение больше не нужно.
+func onecCheckCancelled(ctx context.Context, rc *RuntimeConfig, db *sql.DB) {
+ q := rc.Snapshot().Quik
+ if !quikActive(q) || q.Emulate {
+ return
+ }
+ rows, err := onecQuery(ctx, db, `WHERE status = 'NEW' AND instr_no IS NOT NULL`)
+ if err != nil || len(rows) == 0 {
+ return
+ }
+ items, err := quikListM2MInstructions(ctx, q, 500, false)
+ if err != nil {
+ return
+ }
+ st := map[int64]int{}
+ for _, it := range items {
+ st[onecInstrNo(it.Number)] = it.Status
+ }
+ for _, r := range rows {
+ if s, ok := st[r.InstrNo]; ok && (s == 2 || s == 3) {
+ if l := globalQuikLinks.ByInstrNo(strconv.FormatInt(r.InstrNo, 10)); l != nil && l.State == "denied" {
+ continue // сняли мы — разберёт onecSync
+ }
+ _, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Поручение снято в QUIK', checked_by = 'bj', checked_at = now() WHERE id = $1 AND status = 'NEW'`, r.ID)
+ log.Printf("onec: поручение МНП №%d снято в QUIK до решения 1С", r.InstrNo)
+ }
+ }
+}
+
+// onecApply — решения 1С (и оператора), по которым bj ещё ничего не сделал.
+func onecApply(ctx context.Context, rc *RuntimeConfig, svc *Service, db *sql.DB) {
+ rows, err := onecQuery(ctx, db, `WHERE guid IN (SELECT guid FROM onec.instr WHERE status <> 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER'))
+ AND bj_state IN ('WAIT_1C', 'WAIT_OPER') ORDER BY created_at, id`)
+ if err != nil {
+ log.Printf("onec: решения: %v", err)
+ return
+ }
+ byGUID := map[string][]OneCRow{}
+ var order []string
+ for _, r := range rows {
+ if _, ok := byGUID[r.GUID]; !ok {
+ order = append(order, r.GUID)
+ }
+ byGUID[r.GUID] = append(byGUID[r.GUID], r)
+ }
+ for _, guid := range order {
+ if ctx.Err() != nil {
+ return
+ }
+ if strings.HasPrefix(guid, onecTermPrefix) {
+ onecApplyTerminal(ctx, rc, db, byGUID[guid])
+ continue
+ }
+ onecApplyDeal(ctx, rc, svc, db, guid, byGUID[guid])
+ }
+}
+
+// onecApplyTerminal — поручение из терминала: отказ — снять в МНП; подтверждение — оператору
+// оформить перевод (bj сам такие поручения в НРД не отправляет).
+func onecApplyTerminal(ctx context.Context, rc *RuntimeConfig, db *sql.DB, rows []OneCRow) {
+ for _, r := range rows {
+ switch r.Status {
+ case "CONFIRMED":
+ onecSetState(ctx, db, r.ID, "WAIT_SEND", "подтверждено 1С — перевод оформляет оператор bj")
+ raiseSystemAlert(rc, "Проверка 1С: поручение из терминала подтверждено",
+ fmt.Sprintf("1С подтвердила поручение МНП №%d (клиент %s, %s × %v), поданное клиентом из терминала QUIK. Оформите перевод в bj: ручной ввод или «Привязать» в карточке заявки.", r.InstrNo, r.Client, r.ISIN, r.Qty))
+ case "REJECTED":
+ if _, err := QuikOutDeny(ctx, rc, r.GUID, r.ISIN, "Отказ: "+r.Reason); err != nil {
+ onecAlertOnce(rc, "deny-"+r.GUID, "Проверка 1С: поручение не снято в QUIK",
+ fmt.Sprintf("1С отказала по поручению МНП №%d (%s), но снять его в QUIK не удалось: %v. bj повторит.", r.InstrNo, r.Reason, err))
+ continue
+ }
+ onecAlertClear("deny-" + r.GUID)
+ onecSetState(ctx, db, r.ID, "DENIED", "")
+ case "CANCELLED":
+ onecSetState(ctx, db, r.ID, "DENIED", "")
+ }
+ }
+}
+
+// onecApplyDeal — перевод ждёт решения 1С: все бумаги подтверждены — в НРД; отказ по любой —
+// снять поручения в МНП и отклонить перевод с причиной для клиента.
+func onecApplyDeal(ctx context.Context, rc *RuntimeConfig, svc *Service, db *sql.DB, guid string, rows []OneCRow) {
+ var sts []string
+ reason := ""
+ for _, r := range rows {
+ sts = append(sts, r.Status)
+ if reason == "" && (r.Status == "REJECTED" || r.Status == "CANCELLED") {
+ reason = r.Reason
+ }
+ }
+ d, err := svc.Repo().GetByGUID(ctx, m2m.UUID(guid))
+ if err != nil || d == nil {
+ return
+ }
+ switch onecVerdict(sts) {
+ case "pass":
+ if d.State != m2mcore.StateValidated || d.Request == nil {
+ return // уже отправлен или закрыт — разберёт onecSync
+ }
+ if err := onecRelease(ctx, rc, svc, d); err != nil {
+ onecAlertOnce(rc, "send-"+guid, "Проверка 1С: подтверждённый перевод не отправлен",
+ fmt.Sprintf("Перевод %s подтверждён 1С, но отправить его в НРД не удалось: %v. bj повторит; можно «Отправить повторно» в карточке заявки.", shortGUID(guid), err))
+ return
+ }
+ onecAlertClear("send-" + guid)
+ case "reject":
+ if err := onecRejectDeal(ctx, rc, svc, d, reason); err != nil {
+ onecAlertOnce(rc, "deny-"+guid, "Проверка 1С: отказ не выполнен",
+ fmt.Sprintf("1С отказала по переводу %s (%s), но снять поручение в QUIK не удалось: %v. bj повторит.", shortGUID(guid), reason, err))
+ return
+ }
+ onecAlertClear("deny-" + guid)
+ _, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Перевод отклонён по другой бумаге', checked_by = 'bj', checked_at = now() WHERE guid = $1 AND status = 'NEW'`, guid)
+ _, _ = db.ExecContext(ctx, `UPDATE onec.instr SET bj_state = 'DENIED', bj_state_at = now() WHERE guid = $1 AND bj_state IN ('WAIT_1C', 'WAIT_OPER', 'WAIT_SEND')`, guid)
+ }
+}
+
+// onecRelease — подтверждённый перевод в НРД тем же путём, что «Отправить повторно».
+func onecRelease(ctx context.Context, rc *RuntimeConfig, svc *Service, d *m2mcore.Deal) error {
+ guid := string(d.Request.Header.GUID)
+ sender := realTestSenderFor(rc, guid, d.Request, svc.sender)
+ if nsdEmuMode(rc) == "imitate" && lkEmuSender != nil {
+ sender = lkEmuSender
+ }
+ resp, err := svc.SendBuiltRequestVia(ctx, sender, d.Request, d.InvestorID, d.SignedClaim)
+ if err != nil {
+ if sendFailGet(guid) != nil {
+ return nil // НРД сейчас не принимает пакеты — повтор автоматически (sendfail.go)
+ }
+ return err
+ }
+ if resp.Status == onecWaitStatus {
+ return errors.New("1С ещё не подтвердила все бумаги перевода")
+ }
+ globalLKFlows.stepIfExists(guid, "onec", "Проверка 1С: подтверждено", "1С подтвердила поручения МНП — перевод отправлен в НРД.")
+ if db, err := lkDB(rc); err == nil { // кабинет клиента (bj-lk): «На проверке» → «Отправлена в НРД»
+ _, _ = db.ExecContext(ctx, `UPDATE lk.transfer_requests SET status = 'sent' WHERE guid = $1 AND status = 'checking'`, guid)
+ }
+ log.Printf("onec: %s подтверждён 1С и отправлен в НРД", shortGUID(guid))
+ return nil
+}
+
+// onecRejectDeal — отказ 1С: снять поручения в МНП (клиент видит причину в QUIK), отклонить перевод
+// в журнале bj и в кабинете клиента.
+func onecRejectDeal(ctx context.Context, rc *RuntimeConfig, svc *Service, d *m2mcore.Deal, reason string) error {
+ guid := string(d.GUID)
+ reason = pick(strings.TrimSpace(reason), "отказ по проверке бэк-офиса")
+ for _, l := range globalQuikLinks.ForGUID(guid) {
+ if l.State == "registered" {
+ if _, err := QuikOutDeny(ctx, rc, guid, l.ISIN, "Отказ: "+reason); err != nil {
+ return err
+ }
+ }
+ }
+ short, full := "Отказ: "+reason, "Отказ по проверке 1С: "+reason
+ if id, ok := LKClaimIDByGUID(guid); ok && d.Request != nil {
+ globalLKFlows.step(guid, id, "reject", "Проверка 1С: отказ", reason)
+ lkGatewayReject(ctx, rc, svc, id, d.InvestorID, d.Request, "1C", short, full)
+ } else if !m2mcore.IsTerminal(d.State) {
+ if err := d.RejectManually(ctx, "1С", "1C", full); err != nil {
+ log.Printf("onec: %s: отказ по сделке: %v", shortGUID(guid), err)
+ } else if err := svc.Repo().Update(ctx, d); err != nil {
+ log.Printf("onec: %s: сохранение отказа: %v", shortGUID(guid), err)
+ }
+ }
+ if err := lkFinalizeByGUID(ctx, rc, guid, "rejected", "rejected", short); err != nil {
+ log.Printf("onec: %s: статус в кабинете: %v", shortGUID(guid), err)
+ }
+ log.Printf("onec: %s отклонён по проверке 1С: %s", shortGUID(guid), reason)
+ return nil
+}
+
+// onecSync — ход перевода для 1С (bj_state): отправлен, исполнен, не состоялся, снят.
+func onecSync(ctx context.Context, svc *Service, db *sql.DB) {
+ rows, err := onecQuery(ctx, db, fmt.Sprintf(`WHERE bj_state IN ('WAIT_1C', 'WAIT_OPER', 'WAIT_SEND', 'SENT_TO_NSD') AND created_at > now() - interval '%d days'`, onecKeepDays))
+ if err != nil {
+ return
+ }
+ deals := map[string]*m2mcore.Deal{}
+ for _, r := range rows {
+ l := globalQuikLinks.Get(r.GUID, r.ISIN)
+ var d *m2mcore.Deal
+ if !strings.HasPrefix(r.GUID, onecTermPrefix) {
+ var ok bool
+ if d, ok = deals[r.GUID]; !ok {
+ d, _ = svc.Repo().GetByGUID(ctx, m2m.UUID(r.GUID))
+ deals[r.GUID] = d
+ }
+ }
+ switch {
+ case l != nil && l.State == "signed":
+ onecSetState(ctx, db, r.ID, "EXECUTED", "")
+ case r.BJState == "SENT_TO_NSD" && ((l != nil && l.State == "denied") || (d != nil && (d.State == m2mcore.StateRejected || d.State == m2mcore.StateTimedOut))):
+ onecSetState(ctx, db, r.ID, "FAILED", "")
+ case r.BJState != "SENT_TO_NSD" && ((l != nil && l.State == "denied") || (d != nil && m2mcore.IsTerminal(d.State) && d.State != m2mcore.StateDone)):
+ // сняли или отклонили в bj до отправки в НРД (оператор, отказ по другой бумаге)
+ if r.Status == "NEW" {
+ _, _ = db.ExecContext(ctx, `UPDATE onec.instr SET status = 'CANCELLED', reason = 'Перевод отклонён в bj', checked_by = 'bj', checked_at = now() WHERE id = $1 AND status = 'NEW'`, r.ID)
+ }
+ onecSetState(ctx, db, r.ID, "DENIED", "")
+ case r.BJState == "WAIT_SEND" && d != nil && d.State != m2mcore.StateValidated && d.State != m2mcore.StateDraft:
+ onecSetState(ctx, db, r.ID, "SENT_TO_NSD", "")
+ }
+ }
+}
+
+// onecWatch — сроки решения и связь с 1С.
+func onecWatch(ctx context.Context, rc *RuntimeConfig, db *sql.DB, now time.Time) {
+ s := rc.Snapshot()
+ cs := s.OneC.Check
+ rows, err := onecQuery(ctx, db, `WHERE status = 'NEW' AND alerted_at IS NULL AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`)
+ if err == nil {
+ for _, r := range rows {
+ var title, body string
+ switch {
+ case r.BJState == "WAIT_OPER":
+ title = "Проверка 1С: перевод без поручения МНП ждёт оператора"
+ body = fmt.Sprintf("Перевод %s (%s × %v): в QUIK МНП нет поручения, 1С проверить его не может. Зарегистрируйте поручение в карточке заявки или решите сами: «Подтвердить» / «Отклонить» в блоке «Проверка 1С».", shortGUID(r.GUID), r.ISIN, r.Qty)
+ case now.Sub(r.CreatedAt) >= cs.Timeout() && s.WorkHours.Open(now):
+ title = "Проверка 1С: нет решения по поручению МНП"
+ body = fmt.Sprintf("Поручение МНП №%d (клиент %s, %s × %v) ждёт решения 1С с %s. Перевод в НРД не отправлен. Проверьте 1С или решите в карточке заявки.", r.InstrNo, r.Client, r.ISIN, r.Qty, r.CreatedAt.In(time.Local).Format("02.01 15:04"))
+ default:
+ continue
+ }
+ raiseSystemAlert(rc, title, body)
+ _, _ = db.ExecContext(ctx, `UPDATE onec.instr SET alerted_at = now() WHERE id = $1`, r.ID)
+ }
+ }
+ // Связь: 1С читает очередь раз в минуту. Считаем с момента, когда вход открыт.
+ start := cs.AccessAt
+ if cs.EnabledAt.After(start) {
+ start = cs.EnabledAt
+ }
+ if !cs.AccessOK || now.Sub(start) < onecLostAfter {
+ return
+ }
+ last := onecLastRead(ctx, db)
+ if !last.IsZero() && now.Sub(last) < onecLostAfter {
+ onecAlertClear("lost")
+ return
+ }
+ since := "ни разу не читала очередь"
+ if !last.IsZero() {
+ since = "последнее чтение очереди " + last.In(time.Local).Format("02.01 15:04")
+ }
+ onecAlertOnce(rc, "lost", "Проверка 1С: нет связи с 1С", "1С не забирает поручения на проверку: "+since+". Переводы ждут решения и в НРД не уходят.")
+}
+
+func onecLastRead(ctx context.Context, db *sql.DB) time.Time {
+ var t time.Time
+ if err := db.QueryRowContext(ctx, `SELECT read_at FROM onec.reads WHERE id = 1`).Scan(&t); err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+// ---- решение оператора ----
+
+// onecOperatorDecide — оператор bj решает за 1С (по всем бумагам перевода, ещё ждущим решения).
+func onecOperatorDecide(ctx context.Context, rc *RuntimeConfig, guid, decision, reason, who string) (int64, error) {
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ return 0, err
+ }
+ st := "CONFIRMED"
+ reason = strings.TrimSpace(reason)
+ if decision == "reject" {
+ st = "REJECTED"
+ if reason == "" {
+ return 0, errors.New("при отказе нужна причина — её увидит клиент")
+ }
+ if len([]rune(reason)) > 255 {
+ return 0, errors.New("причина длиннее 255 символов")
+ }
+ } else {
+ reason = ""
+ }
+ res, err := db.ExecContext(ctx, `UPDATE onec.instr SET status = $2, reason = $3, checked_by = $4, checked_at = now(), by_operator = true
+ WHERE guid = $1 AND status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`, guid, st, onecNull(reason), "оператор bj: "+pick(who, "—"))
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ if n > 0 {
+ log.Printf("onec: %s: решение оператора %s (%s)", shortGUID(guid), st, who)
+ onecKickNow()
+ }
+ return n, nil
+}
+
+func registerOneCOps(mux *http.ServeMux, rc *RuntimeConfig) {
+ mux.HandleFunc("/admin/onec/decide", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ guid := strings.TrimSpace(r.FormValue("guid"))
+ back := r.FormValue("back")
+ if !strings.HasPrefix(back, "/admin/") {
+ back = "/admin/claims"
+ }
+ who := ""
+ if u := currentUser(w); u != nil {
+ who = pick(u.Name, u.Login)
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ n, err := onecOperatorDecide(ctx, rc, guid, r.FormValue("decision"), r.FormValue("reason"), who)
+ msg := "Проверка 1С: решение записано — bj выполнит его в течение минуты"
+ switch {
+ case err != nil:
+ msg = "Проверка 1С: " + err.Error()
+ case n == 0:
+ msg = "Проверка 1С: по переводу нет поручений, ждущих решения"
+ }
+ sep := "?"
+ if strings.Contains(back, "?") {
+ sep = "&"
+ }
+ http.Redirect(w, r, back+sep+"flash="+urlQ(msg), http.StatusSeeOther)
+ })
+}
+
+// ---- показ ----
+
+// StatusRu — решение по-русски.
+func (r OneCRow) StatusRu() string {
+ switch r.Status {
+ case "NEW":
+ if r.BJState == "WAIT_OPER" {
+ return "ждёт оператора"
+ }
+ return "ждёт решения 1С"
+ case "CONFIRMED":
+ return "подтверждено"
+ case "REJECTED":
+ return "отказано"
+ case "CANCELLED":
+ return "снято"
+ }
+ return r.Status
+}
+
+// BJStateRu — что сделал bj.
+func (r OneCRow) BJStateRu() string {
+ switch r.BJState {
+ case "WAIT_1C":
+ return "ждёт решения"
+ case "WAIT_OPER":
+ return "нет поручения МНП — решает оператор"
+ case "WAIT_SEND":
+ if strings.HasPrefix(r.GUID, onecTermPrefix) {
+ return "перевод оформляет оператор"
+ }
+ return "отправляется в НРД"
+ case "SENT_TO_NSD":
+ return "отправлен в НРД"
+ case "EXECUTED":
+ return "исполнен, бумаги списаны"
+ case "DENIED":
+ return "снят в МНП"
+ case "FAILED":
+ return "перевод не состоялся"
+ }
+ return r.BJState
+}
+
+// Waiting — строка ждёт решения (кнопки оператора).
+func (r OneCRow) Waiting() bool {
+ return r.Status == "NEW" && (r.BJState == "WAIT_1C" || r.BJState == "WAIT_OPER")
+}
+
+// OneCWaiting — по переводу есть поручения, ждущие решения (карточка заявки).
+func (d claimData) OneCWaiting() bool {
+ for _, r := range d.OneC {
+ if r.Waiting() {
+ return true
+ }
+ }
+ return false
+}
+
+// onecRowsFor — строки очереди 1С по переводу (карточка заявки).
+func onecRowsFor(ctx context.Context, rc *RuntimeConfig, guid string) []OneCRow {
+ if guid == "" || !moduleAllowed(rc, "onec-back") || !rc.Snapshot().OneC.Check.Enabled {
+ return nil
+ }
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ return nil
+ }
+ cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ rows, _ := onecQuery(cctx, db, `WHERE guid = $1 ORDER BY id`, guid)
+ return rows
+}
+
+// OneCCheckView — блок «Проверка поручений в 1С» в настройках.
+type OneCCheckView struct {
+ QuikOK bool // QUIK МНП подключён и сам регистрирует «Вывод ЦБ (M2M)»
+ DBErr string
+ Waiting int // ждут решения
+ LastRead time.Time
+ Rows []OneCRow
+ Password, Host string // выданный пароль onec_bj (показывается один раз)
+}
+
+var onecPwShow struct {
+ sync.Mutex
+ pw, host string
+ at time.Time
+}
+
+func onecCheckView(ctx context.Context, rc *RuntimeConfig) OneCCheckView {
+ s := rc.Snapshot()
+ v := OneCCheckView{QuikOK: quikActive(s.Quik) && s.Quik.AutoOut}
+ onecPwShow.Lock()
+ if onecPwShow.pw != "" && time.Since(onecPwShow.at) < 15*time.Minute {
+ v.Password, v.Host = onecPwShow.pw, onecPwShow.host
+ }
+ onecPwShow.pw = ""
+ onecPwShow.Unlock()
+ if !s.OneC.Check.Enabled {
+ return v
+ }
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ v.DBErr = err.Error()
+ return v
+ }
+ cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ _ = db.QueryRowContext(cctx, `SELECT count(*) FROM onec.instr WHERE status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER')`).Scan(&v.Waiting)
+ v.LastRead = onecLastRead(cctx, db)
+ v.Rows, _ = onecQuery(cctx, db, `ORDER BY id DESC LIMIT 15`)
+ return v
+}
+
+// ---- настройки ----
+
+// onecParseIPs — IP или подсети сервера 1С (IPv4), через запятую или пробел.
+func onecParseIPs(raw string) ([]string, error) {
+ var out []string
+ for _, f := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' }) {
+ p := f
+ if !strings.Contains(p, "/") {
+ p += "/32"
+ }
+ ip, n, err := net.ParseCIDR(p)
+ if err != nil || ip.To4() == nil {
+ return nil, fmt.Errorf("«%s» — не IPv4-адрес", f)
+ }
+ if ones, _ := n.Mask.Size(); ones < 24 {
+ return nil, fmt.Errorf("«%s» — слишком широкая подсеть, нужен адрес сервера 1С (не шире /24)", f)
+ }
+ out = append(out, n.String())
+ }
+ if len(out) > 8 {
+ return nil, errors.New("не больше 8 адресов")
+ }
+ return out, nil
+}
+
+func onecPassword() (string, error) {
+ b := make([]byte, onecPwLen)
+ max := big.NewInt(int64(len(onecPwAlphabet)))
+ for i := range b {
+ n, err := rand.Int(rand.Reader, max)
+ if err != nil {
+ return "", err
+ }
+ b[i] = onecPwAlphabet[n.Int64()]
+ }
+ return string(b), nil
+}
+
+// saveOneCCheck — POST /admin/setup/onec/check: включить/выключить проверку, IP 1С, срок.
+func (h *setupHandlers) saveOneCCheck(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ o := h.rc.Snapshot().OneC
+ c := o.Check
+ on := r.FormValue("enabled") != ""
+ ips, err := onecParseIPs(r.FormValue("client_ips"))
+ if err != nil {
+ setupFlash(w, r, "Проверка 1С: "+err.Error())
+ return
+ }
+ c.ClientIPs = strings.Join(ips, ", ")
+ c.TimeoutMin, _ = strconv.Atoi(strings.TrimSpace(r.FormValue("timeout_min")))
+ if c.TimeoutMin < 5 || c.TimeoutMin > 24*60 {
+ c.TimeoutMin = 30
+ }
+ msg := "Проверка 1С: настройки сохранены"
+ ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
+ defer cancel()
+ switch {
+ case on && !c.Enabled:
+ c.Enabled, c.EnabledAt = true, time.Now()
+ msg = "Проверка 1С включена: исходящие переводы уходят в НРД только после решения 1С"
+ case !on && c.Enabled:
+ c.Enabled = false
+ msg = "Проверка 1С выключена: переводы идут в НРД без решения 1С"
+ if db, err := onecDB(ctx, h.rc); err == nil {
+ var n int
+ _ = db.QueryRowContext(ctx, `SELECT count(DISTINCT guid) FROM onec.instr WHERE status = 'NEW' AND bj_state IN ('WAIT_1C', 'WAIT_OPER') AND guid NOT LIKE 'terminal-%'`).Scan(&n)
+ if n > 0 {
+ msg += fmt.Sprintf(". Переводов, ждавших решения, — %d: они остались «Не отправлена», отправьте их из карточки заявки («Отправить повторно») или отклоните", n)
+ }
+ }
+ if c.AccessOK {
+ if _, err := runKeymediaOp(ctx, "onec-revoke", map[string]string{}); err != nil {
+ msg += "; вход логином " + onecLogin + " закрыть не удалось: " + err.Error()
+ } else {
+ c.AccessOK, c.AccessMsg = false, "вход закрыт при выключении проверки"
+ msg += "; вход логином " + onecLogin + " закрыт"
+ }
+ }
+ }
+ o.Check = c
+ if err := h.rc.UpdateOneC(o); err != nil {
+ setupFlash(w, r, "Проверка 1С: настройки не сохранены — "+err.Error())
+ return
+ }
+ if c.Enabled {
+ if _, err := onecDB(ctx, h.rc); err != nil {
+ msg += "; объекты для 1С в базе bj не созданы: " + err.Error()
+ }
+ }
+ setupFlash(w, r, msg)
+}
+
+// saveOneCAccess — POST /admin/setup/onec/access: завести логин onec_bj с новым паролем и открыть
+// вход в базу bj с IP сервера 1С (root-скрипт bj-onec-admin). Пароль показывается один раз.
+func (h *setupHandlers) saveOneCAccess(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ o := h.rc.Snapshot().OneC
+ c := o.Check
+ if !c.Enabled {
+ setupFlash(w, r, "Проверка 1С: сначала включите проверку")
+ return
+ }
+ ips, err := onecParseIPs(c.ClientIPs)
+ if err != nil || len(ips) == 0 {
+ setupFlash(w, r, "Проверка 1С: укажите IP-адрес сервера 1С и сохраните")
+ return
+ }
+ pw, err := onecPassword()
+ if err != nil {
+ setupFlash(w, r, "Проверка 1С: пароль не создан — "+err.Error())
+ return
+ }
+ extra, _ := json.Marshal(map[string]string{"password": pw, "ips": strings.Join(ips, ",")})
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
+ defer cancel()
+ res, err := runKeymediaOp(ctx, "onec-access", map[string]string{"extra": string(extra)})
+ c.AccessAt = time.Now()
+ if err != nil {
+ c.AccessOK, c.AccessMsg = false, err.Error()
+ o.Check = c
+ _ = h.rc.UpdateOneC(o)
+ setupFlash(w, r, "Проверка 1С: вход для 1С не открыт — "+err.Error())
+ return
+ }
+ out, _ := res["output"].(string)
+ host, _ := res["host"].(string)
+ c.AccessOK, c.AccessMsg = true, out
+ o.Check = c
+ if err := h.rc.UpdateOneC(o); err != nil {
+ setupFlash(w, r, "Проверка 1С: настройки не сохранены — "+err.Error())
+ return
+ }
+ onecResetGrants()
+ msg := "Проверка 1С: вход для 1С открыт — " + out + ". Пароль показан ниже один раз"
+ if _, err := onecDB(ctx, h.rc); err != nil {
+ msg += "; права логину " + onecLogin + " не выданы: " + err.Error()
+ }
+ onecPwShow.Lock()
+ onecPwShow.pw, onecPwShow.host, onecPwShow.at = pw, host, time.Now()
+ onecPwShow.Unlock()
+ setupFlash(w, r, msg)
+}
+
+// saveOneCRevoke — POST /admin/setup/onec/revoke: закрыть вход логином onec_bj.
+func (h *setupHandlers) saveOneCRevoke(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method", http.StatusMethodNotAllowed)
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
+ defer cancel()
+ if _, err := runKeymediaOp(ctx, "onec-revoke", map[string]string{}); err != nil {
+ setupFlash(w, r, "Проверка 1С: вход не закрыт — "+err.Error())
+ return
+ }
+ o := h.rc.Snapshot().OneC
+ o.Check.AccessOK, o.Check.AccessMsg, o.Check.AccessAt = false, "вход закрыт", time.Now()
+ _ = h.rc.UpdateOneC(o)
+ setupFlash(w, r, "Проверка 1С: вход логином "+onecLogin+" закрыт")
+}
diff --git a/internal/lkgateway/onec_check_test.go b/internal/lkgateway/onec_check_test.go
new file mode 100644
index 0000000..1f1bd64
--- /dev/null
+++ b/internal/lkgateway/onec_check_test.go
@@ -0,0 +1,215 @@
+package lkgateway
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
+)
+
+func TestOneCVerdict(t *testing.T) {
+ for _, c := range []struct {
+ in []string
+ want string
+ }{
+ {nil, "wait"},
+ {[]string{"NEW"}, "wait"},
+ {[]string{"CONFIRMED", "NEW"}, "wait"},
+ {[]string{"CONFIRMED", "CONFIRMED"}, "pass"},
+ {[]string{"CONFIRMED", "REJECTED"}, "reject"},
+ {[]string{"NEW", "CANCELLED"}, "reject"},
+ } {
+ if got := onecVerdict(c.in); got != c.want {
+ t.Errorf("onecVerdict(%v) = %s, нужно %s", c.in, got, c.want)
+ }
+ }
+}
+
+func TestOneCParseIPs(t *testing.T) {
+ got, err := onecParseIPs("10.0.0.15, 10.0.1.0/24;10.0.0.16")
+ if err != nil || strings.Join(got, ",") != "10.0.0.15/32,10.0.1.0/24,10.0.0.16/32" {
+ t.Fatalf("got %v, %v", got, err)
+ }
+ for _, bad := range []string{"10.0.0.0/16", "1c-server", "::1", "10.0.0.300"} {
+ if _, err := onecParseIPs(bad); err == nil {
+ t.Errorf("%q должен быть отвергнут", bad)
+ }
+ }
+}
+
+func TestOneCInstrNo(t *testing.T) {
+ for in, want := range map[string]int64{"123456": 123456, " 77 ": 77, "1.5e3": 1500, "": 0, "0": 0, "abc": 0, "12.5": 0} {
+ if got := onecInstrNo(in); got != want {
+ t.Errorf("onecInstrNo(%q) = %d, нужно %d", in, got, want)
+ }
+ }
+}
+
+func TestOneCPassword(t *testing.T) {
+ pw, err := onecPassword()
+ if err != nil || len(pw) != onecPwLen || strings.Trim(pw, onecPwAlphabet) != "" {
+ t.Fatalf("пароль %q, %v", pw, err)
+ }
+}
+
+func TestOneCTemplates(t *testing.T) {
+ a, err := newAdmin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ rows := []OneCRow{
+ {GUID: "g1", ISIN: "RU0009029540", InstrNo: 1001, Qty: 10, Status: "NEW", BJState: "WAIT_1C", CreatedAt: now},
+ {GUID: "g1", ISIN: "RU0009029557", InstrNo: 1002, Qty: 5, Status: "REJECTED", Reason: "Недостаточно бумаг", CheckedBy: "Иванова А. С.", CheckedAt: now, BJState: "WAIT_1C"},
+ }
+ rec := httptest.NewRecorder()
+ render(rec, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: ClaimView{ID: "0123456789abcdef", M2MGUID: "g1", Status: "validated"}, OneC: rows})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("карточка: код %d: %s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"Проверка 1С", "№1001", "ждёт решения 1С", "Недостаточно бумаг", "Перевод ждёт решения 1С"} { // кнопки решения — только с правом operate
+ if !strings.Contains(body, want) {
+ t.Errorf("в карточке нет %q", want)
+ }
+ }
+ var s Settings
+ s.OneC.Check = OneCCheckSettings{Enabled: true, ClientIPs: "10.0.0.15/32", AccessOK: true, AccessAt: now, AccessMsg: "логин onec_bj"}
+ rec = httptest.NewRecorder()
+ render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), Settings: s,
+ OneCCheck: OneCCheckView{Waiting: 1, Rows: rows, Password: "Secret123", Host: "10.10.10.9"}})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("настройки: код %d: %s", rec.Code, rec.Body.String())
+ }
+ body = rec.Body.String()
+ for _, want := range []string{"Проверка поручений в 1С", "10.0.0.15/32", "Secret123", "Выдать новый пароль", "Закрыть вход", "ждёт решения 1С"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("в настройках нет %q", want)
+ }
+ }
+}
+
+// TestOneCLive — объекты для 1С на настоящей PostgreSQL: BJ_ONEC_LIVE_DSN — база bj (владелец bj),
+// BJ_ONEC_LIVE_1C_DSN — та же база под логином onec_bj (вход открыт bj-onec-admin access).
+func TestOneCLive(t *testing.T) {
+ dsn, dsn1c := os.Getenv("BJ_ONEC_LIVE_DSN"), os.Getenv("BJ_ONEC_LIVE_1C_DSN")
+ if dsn == "" || dsn1c == "" {
+ t.Skip("BJ_ONEC_LIVE_DSN / BJ_ONEC_LIVE_1C_DSN не заданы")
+ }
+ ctx := context.Background()
+ rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
+ t.Fatal(err)
+ }
+ saved := globalQuikLinks
+ globalQuikLinks = newQuikLinkStore(t.TempDir() + "/quik.json")
+ defer func() { globalQuikLinks = saved }()
+
+ db, err := onecDB(ctx, rc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := 990000000000 + time.Now().Unix()%1000000*10
+ guid := fmt.Sprintf("zz-onec-live-%d", base)
+ defer db.ExecContext(ctx, `DELETE FROM onec.instr WHERE guid LIKE 'zz-onec-live-%'`)
+
+ isins := []string{"RU000ZZTST01", "RU000ZZTST02", "RU000ZZTST03"}
+ req := &m2m.M2MTransferRequest{}
+ req.Header.GUID = m2m.UUID(guid)
+ for i, isin := range isins {
+ is, n := m2m.ISIN(isin), uint64(10*(i+1))
+ req.Data.TransferredSecurities.Securities = append(req.Data.TransferredSecurities.Securities,
+ m2m.RequestSecurity{SecurityCode: m2m.SecurityCode(fmt.Sprintf("ZZ%d", i)), SecurityDetails: m2m.SecurityDetails{ISIN: &is}, Quantity: m2m.Quantity{Whole: &n}})
+ if i < 2 { // третья бумага — без поручения МНП (решает оператор)
+ no := fmt.Sprint(base + int64(i))
+ globalQuikLinks.update(guid, isin, func(l *QuikLink) {
+ l.Direction, l.ClientID, l.InstrNo, l.State, l.Qty = "out", "C-ZZ", no, "registered", float64(n)
+ })
+ }
+ }
+ if err := onecUpsert(ctx, db, req); err != nil {
+ t.Fatal(err)
+ }
+ if err := onecUpsert(ctx, db, req); err != nil { // повтор — без дублей
+ t.Fatal(err)
+ }
+ sts, err := onecRequestStatuses(ctx, db, req)
+ if err != nil || strings.Join(sts, ",") != "NEW,NEW,NEW" || onecVerdict(sts) != "wait" {
+ t.Fatalf("статусы %v, %v", sts, err)
+ }
+
+ one, err := sql.Open("pgx", dsn1c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer one.Close()
+ var n int
+ if err := one.QueryRowContext(ctx, `SELECT count(*) FROM onec.instr_queue WHERE status = 'NEW' AND instr_no BETWEEN $1 AND $2`, base, base+1).Scan(&n); err != nil || n != 2 {
+ t.Fatalf("1С видит в очереди %d строк (нужно 2, строка без МНП не видна): %v", n, err)
+ }
+ if last := onecLastRead(ctx, db); time.Since(last) > time.Minute {
+ t.Errorf("чтение очереди не отмечено: %v", last)
+ }
+ call := func(no int64, st, reason string) string {
+ var out string
+ if err := one.QueryRowContext(ctx, `SELECT onec.set_decision($1, $2, $3, 'тест bj', NULL)`, no, st, reason).Scan(&out); err != nil {
+ t.Fatalf("set_decision: %v", err)
+ }
+ return out
+ }
+ for _, c := range []struct {
+ no int64
+ st, reason, ok string
+ }{
+ {base, "CONFIRMED", "", "OK"},
+ {base, "confirmed", "", "ALREADY"},
+ {base, "REJECTED", "передумали", "CONFLICT"},
+ {base + 1, "REJECTED", "", "ERROR: при отказе нужна причина"},
+ {base + 1, "MAYBE", "", "ERROR: статус — CONFIRMED или REJECTED"},
+ {base + 7, "CONFIRMED", "", "NOT_FOUND"},
+ {base + 1, "CONFIRMED", "", "OK"},
+ } {
+ if got := call(c.no, c.st, c.reason); got != c.ok {
+ t.Errorf("set_decision(%d, %s) = %q, нужно %q", c.no, c.st, got, c.ok)
+ }
+ }
+ // 1С — только очередь и решение
+ for _, q := range []string{`SELECT count(*) FROM onec.instr`, `SELECT count(*) FROM onec.reads`, `UPDATE onec.instr SET status = 'CONFIRMED'`, `CREATE TABLE public.zz_onec (x int)`, `SELECT count(*) FROM lk.transfer_requests`} {
+ if _, err := one.ExecContext(ctx, q); err == nil || !strings.Contains(err.Error(), "42501") { // insufficient_privilege
+ t.Errorf("%s под onec_bj: нужен отказ в доступе, получено %v", q, err)
+ }
+ }
+ sts, _ = onecRequestStatuses(ctx, db, req)
+ if onecVerdict(sts) != "wait" {
+ t.Fatalf("без решения по бумаге без МНП перевод не уходит: %v", sts)
+ }
+ if n, err := onecOperatorDecide(ctx, rc, guid, "confirm", "", "тест"); err != nil || n != 1 {
+ t.Fatalf("решение оператора: %d, %v", n, err)
+ }
+ sts, _ = onecRequestStatuses(ctx, db, req)
+ if onecVerdict(sts) != "pass" {
+ t.Fatalf("все подтверждены — перевод уходит: %v", sts)
+ }
+ rows := onecRowsForTest(ctx, t, db, guid)
+ if len(rows) != 3 || !rows[2].ByOperator || rows[0].CheckedBy != "тест bj" {
+ t.Fatalf("строки %+v", rows)
+ }
+}
+
+func onecRowsForTest(ctx context.Context, t *testing.T, db *sql.DB, guid string) []OneCRow {
+ rows, err := onecQuery(ctx, db, `WHERE guid = $1 ORDER BY id`, guid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rows
+}
diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go
index 1170ea6..2ebe370 100644
--- a/internal/lkgateway/server.go
+++ b/internal/lkgateway/server.go
@@ -229,6 +229,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux)
registerQuikOps(mux, rc, svc)
+ registerOneCOps(mux, rc)
registerNPR(mux, adminTpl, rc)
registerAuth(mux, rc)
registerSetCallback(mux, svc, rc)
@@ -347,6 +348,12 @@ func (s *Server) Run(ctx context.Context) error {
quikSendHook = func(ctx context.Context, req *m2m.M2MTransferRequest) error {
return quikOutgoingBeforeSend(ctx, s.rc, req)
}
+ // Проверка поручений в 1С (модуль onec-back, включается отдельно): перевод — в НРД после решения 1С.
+ onecSendHook = func(ctx context.Context, req *m2m.M2MTransferRequest) (bool, error) {
+ return onecGate(ctx, s.rc, req)
+ }
+ healthRegister("onec-check", 2*time.Minute)
+ go superviseLoop(ctx, "onec-check", s.rc, s.onecLoop)
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
diff --git a/internal/lkgateway/service.go b/internal/lkgateway/service.go
index 13df098..5fb6615 100644
--- a/internal/lkgateway/service.go
+++ b/internal/lkgateway/service.go
@@ -70,6 +70,10 @@ var quikSendHook func(ctx context.Context, req *m2m.M2MTransferRequest) error
// sentHook — вызывается после успешной отправки M2MTR (запись в трансферные таблицы DEPO).
var sentHook func(req *m2m.M2MTransferRequest)
+// onecSendHook — проверка поручений в 1С (onec_check.go): true — перевод ждёт решения 1С и в НРД
+// сейчас не уходит (заявка остаётся «Не отправлена», отправит onecLoop). Назначается в server.go.
+var onecSendHook func(ctx context.Context, req *m2m.M2MTransferRequest) (bool, error)
+
func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (CreateClaimResponse, error) {
req, err := s.BuildRequest(ctx, in)
if err != nil {
@@ -184,6 +188,19 @@ func (s *Service) SendBuiltRequestVia(ctx context.Context, sender m2mcore.NSDSen
return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
}
}
+ // Проверка 1С: бумаги уже заблокированы в МНП, в НРД — только после решения 1С.
+ if onecSendHook != nil {
+ wait, err := onecSendHook(ctx, req)
+ if err != nil {
+ return CreateClaimResponse{}, fmt.Errorf("lkgateway: %w", err)
+ }
+ if wait {
+ if !resend {
+ _ = s.repo.AppendEvent(ctx, saved.ID, m2mcore.Event{Type: "onec_wait", CreatedAt: time.Now().UTC(), Actor: "system"})
+ }
+ return CreateClaimResponse{ID: saved.ID, Status: onecWaitStatus, CreatedAt: saved.CreatedAt, Success: true}, nil
+ }
+ }
resp, err := sender.Send(ctx, req)
if err != nil {
s.noteSendFailure(ctx, saved, req, err)
diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go
index 4cadbcd..0806f92 100644
--- a/internal/lkgateway/setup.go
+++ b/internal/lkgateway/setup.go
@@ -107,6 +107,9 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/lk-pull", h.lkPull)
mux.HandleFunc("/admin/setup/workhours", h.saveWorkHours)
mux.HandleFunc("/admin/setup/onec", h.saveOneC)
+ mux.HandleFunc("/admin/setup/onec/check", h.saveOneCCheck)
+ mux.HandleFunc("/admin/setup/onec/access", h.saveOneCAccess)
+ mux.HandleFunc("/admin/setup/onec/revoke", h.saveOneCRevoke)
mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP)
mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP)
mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix)
@@ -570,6 +573,7 @@ type SetupData struct {
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки
Users UsersView // пользователи, роли, сессии
+ OneCCheck OneCCheckView // проверка поручений в 1С
Flash string
Error string
}
@@ -593,6 +597,8 @@ func HealthLabel(name string) string {
return "Автоматический режим M2M (решения, 16/1, форма 16)"
case "depo-job":
return "Обработка трансферных таблиц DEPO силами bj"
+ case "onec-check":
+ return "Проверка поручений в 1С"
}
return name
}
@@ -649,6 +655,9 @@ func (h *setupHandlers) setupData(w http.ResponseWriter, req *http.Request, flas
Flash: flash,
}
data.LKUsers, data.LKUsersError = lkUsersForSetup(h.rc)
+ if moduleAllowed(h.rc, "onec-back") {
+ data.OneCCheck = onecCheckView(req.Context(), h.rc)
+ }
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
data.Error = errVal
}
diff --git a/internal/lkgateway/web/templates/admin_claim.html b/internal/lkgateway/web/templates/admin_claim.html
index ca7e7a5..3138ac9 100644
--- a/internal/lkgateway/web/templates/admin_claim.html
+++ b/internal/lkgateway/web/templates/admin_claim.html
@@ -45,7 +45,10 @@
Перевод не доставлен в НРД — {{sendFailText .}}.
Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}}
{{end}}
- {{if and .Claim.RequestRawXML (not .SentToNSD) (not .Fail)}}
M2MTransferRequest сформирован, но в НРД не направлялся: заявка остановлена на проверке перед отправкой{{if .QuikFailReply}} — в QUIK МНП не удалось заблокировать бумаги{{if .QuikFailISIN}} ({{.QuikFailISIN}}){{end}}{{if .QuikNoSec}}: на счёте клиента нет/недостаточно бумаг для списания{{end}}{{end}}. Регистрация «Вывод ЦБ (M2M)» и блокировка идут в QUIK МНП до отправки в НРД, поэтому поручение 16 и запрос перевода в НРД не создавались. Устраните причину{{if .QuikNoSec}} (зачислите бумаги на счёт клиента){{end}} и отправьте заявку повторно.{{if .QuikFailReply}}{{.QuikFailReply}}{{end}}
{{.ISIN}}{{if .Qty}} × {{.Qty}}{{end}}
+ {{if .InstrNo}}поручение МНП №{{.InstrNo}}{{else}}поручения МНП нет — 1С проверить нечего{{end}}
+ {{.StatusRu}}
+ bj: {{.BJStateRu}}
+ Решение оператора окончательное: 1С по этому поручению уже ничего не изменит.
+ {{end}} +