1С: проверка поручений МНП перед отправкой в НРД

Часть модуля onec-back, по умолчанию выключена, включается в настройках
(«Проверка поручений в 1С»). Исходящий перевод после блокировки бумаг в
МНП ждёт решения 1С и в НРД не уходит, пока 1С не подтвердит каждое
поручение; при отказе bj снимает поручения в QUIK с причиной из 1С и
отклоняет перевод в журнале и кабинете клиента.

- onec.instr / onec.instr_queue / onec.set_decision в базе bj: 1С читает
  очередь и пишет решение под логином onec_bj (только эти права); чтение
  очереди отмечает связь с 1С.
- bj-onec-admin (root, через bj-keymedia): логин onec_bj, вход только с
  IP 1С по TLS (pg_hba.conf), listen_addresses, ufw; revoke закрывает вход.
- onecLoop: отправка подтверждённых, отказы, снятие клиентом в QUIK,
  поручения из терминала, сроки решения и потеря связи — оповещения.
- Карточка заявки: блок «Проверка 1С», решение оператора вместо 1С.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
fontvielle
2026-10-22 20:13:59 +03:00
parent e51962c5bb
commit 21d5f33394
13 changed files with 1598 additions and 5 deletions
+2
View File
@@ -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) ;;
# Перезапуск службы модуля из админки. Список закрытый: чужую службу
+104
View File
@@ -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
+6 -1
View File
@@ -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,
+1 -1
View File
@@ -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 ""
+9 -1
View File
@@ -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",
+2
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+215
View File
@@ -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
}
+7
View File
@@ -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
+17
View File
@@ -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)
+9
View File
@@ -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
}
@@ -45,7 +45,10 @@
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
<span class="muted">Попытка {{.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}}</span>
</div>{{end}}
{{if and .Claim.RequestRawXML (not .SentToNSD) (not .Fail)}}<div class="card" style="background:var(--warn-weak,#fbf3d9);margin:8px 0">
{{if and .OneCWaiting (not .SentToNSD)}}<div class="card" style="background:var(--warn-weak,#fbf3d9);margin:8px 0">
<b>⏳ Перевод ждёт решения 1С.</b><br>
<span class="muted">Бумаги заблокированы в QUIK МНП. В НРД перевод уйдёт сам, как только 1С подтвердит поручения; при отказе bj снимет поручения в QUIK с причиной из 1С. Решить вместо 1С — в блоке «Проверка 1С» ниже.</span>
{{else if and .Claim.RequestRawXML (not .SentToNSD) (not .Fail)}}<div class="card" style="background:var(--warn-weak,#fbf3d9);margin:8px 0">
<b>⛔ В НРД ничего не отправлено — отмена до отправки.</b><br>
<span class="muted">Пакет <code>M2MTransferRequest</code> сформирован, но в НРД <b>не направлялся</b>: заявка остановлена на проверке перед отправкой{{if .QuikFailReply}} — в QUIK МНП не удалось заблокировать бумаги{{if .QuikFailISIN}} ({{.QuikFailISIN}}){{end}}{{if .QuikNoSec}}: на счёте клиента нет/недостаточно бумаг для списания{{end}}{{end}}. Регистрация «Вывод ЦБ (M2M)» и блокировка идут в QUIK МНП <u>до</u> отправки в НРД, поэтому поручение 16 и запрос перевода в НРД не создавались. Устраните причину{{if .QuikNoSec}} (зачислите бумаги на счёт клиента){{end}} и отправьте заявку повторно.{{if .QuikFailReply}}<br>Ответ модуля QUIK: <code>{{.QuikFailReply}}</code>{{end}}</span>
</div>{{end}}
@@ -114,6 +117,32 @@
{{/* ===== QUIK МНП: поручение «Вывод ЦБ (M2M)» ===== */}}
{{$back := printf "/admin/claims/%s" .Claim.ID}}
{{if .OneC}}
<div class="card">
<h2 style="margin:0">Проверка 1С</h2>
{{range .OneC}}
<div style="border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:10px;font-size:13.5px">
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<b><code>{{.ISIN}}</code>{{if .Qty}} × {{.Qty}}{{end}}</b>
<span class="muted" style="font-size:12px">{{if .InstrNo}}поручение МНП <b>№{{.InstrNo}}</b>{{else}}поручения МНП нет — 1С проверить нечего{{end}}</span>
<span class="qk-state {{if eq .Status "CONFIRMED"}}signed{{else if eq .Status "REJECTED"}}error{{else if eq .Status "CANCELLED"}}denied{{else}}registered{{end}}">{{.StatusRu}}</span>
<span class="muted" style="font-size:12px">bj: {{.BJStateRu}}</span>
</div>
{{if .Reason}}<div style="font-size:12.5px;margin-top:4px;color:#b3261e">Причина: {{.Reason}}</div>{{end}}
{{if .CheckedBy}}<div class="muted" style="font-size:12px;margin-top:2px">{{.CheckedBy}}{{if not .CheckedAt.IsZero}}, {{msk .CheckedAt}}{{end}}{{if .OneCDoc}} · {{.OneCDoc}}{{end}}</div>{{end}}
</div>
{{end}}
{{if and .OneCWaiting (can "operate")}}
<form method="post" action="/admin/onec/decide" style="margin:10px 0 0;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="hidden" name="guid" value="{{.Claim.M2MGUID}}"><input type="hidden" name="back" value="{{$back}}">
<button class="btn btn-ok" name="decision" value="confirm" style="font-size:12px" onclick="return confirm('Подтвердить вместо 1С? Перевод уйдёт в НРД.')">✓ Подтвердить вместо 1С</button>
<input type="text" name="reason" maxlength="255" placeholder="причина отказа — её увидит клиент" style="font-size:12px;width:260px">
<button class="btn" name="decision" value="reject" style="font-size:12px" onclick="return confirm('Отклонить перевод? Поручения в QUIK будут сняты, клиент увидит причину.')">✕ Отклонить</button>
</form>
<p class="muted" style="font-size:12px;margin:6px 0 0">Решение оператора окончательное: 1С по этому поручению уже ничего не изменит.</p>
{{end}}
</div>
{{end}}
<div class="card">
<style>
.qk-steps{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
@@ -811,7 +811,7 @@
{{if mod "onec-back"}}
<section class="settings-section" id="sec-onec">
<h1>1С: база бэк-офиса</h1>
<p class="muted" style="margin:-8px 0 16px">Связь bj с базой 1С бэк-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.</p>
<p class="muted" style="margin:-8px 0 16px">Связь bj с базой 1С бэк-офиса: подключение к базе через OData и проверка поручений на вывод бумаг в 1С перед отправкой в НРД.</p>
<div class="card">
<h2><span class="dot {{if .Settings.OneC.CheckOK}}ok{{else}}warn{{end}}"></span>Подключение к базе 1С</h2>
<p class="muted">bj обращается к базе через стандартный интерфейс OData. Его включают при публикации базы: конфигуратор 1С → Администрирование → Публикация на веб-сервере → «Публиковать стандартный интерфейс OData».</p>
@@ -830,6 +830,44 @@
</form>
{{with .Settings.OneC}}{{if not .CheckedAt.IsZero}}<p style="margin:14px 0 0;font-size:13.5px"><b style="color:var(--{{if .CheckOK}}ok{{else}}err{{end}})">{{if .CheckOK}}✓ Подключение работает{{else}}✗ Подключение не работает{{end}}</b> — {{.CheckMsg}} <span class="muted">(проверено {{.CheckedAt.Format "02.01.2006 15:04"}})</span></p>{{end}}{{end}}
</div>
{{$oc := .Settings.OneC.Check}}
<div class="card" id="onec-check">
<h2><span class="dot {{if not $oc.Enabled}}off{{else if $oc.AccessOK}}ok{{else}}warn{{end}}"></span>Проверка поручений в 1С</h2>
<p class="muted">Исходящий перевод уходит в НРД только после того, как 1С подтвердила поручение МНП «Вывод ЦБ (M2M)». 1С забирает поручения из базы bj и записывает решение; при отказе bj снимает поручение в QUIK с причиной из 1С, клиент видит её в терминале. По умолчанию выключено.</p>
{{if and $oc.Enabled (not .OneCCheck.QuikOK)}}<div class="alert alert-warn" style="margin-top:10px">QUIK МНП сейчас не регистрирует поручения на вывод сам (цикл QUIK выключен или вывод не автоматический). Переводы без поручения МНП 1С проверить не может — их подтверждает или отклоняет оператор в карточке заявки.</div>{{end}}
<form method="post" action="/admin/setup/onec/check" style="margin-top:12px;display:grid;gap:12px;max-width:640px">
<label style="font-size:14px"><input type="checkbox" name="enabled" value="1" {{if $oc.Enabled}}checked{{end}}> Включить проверку поручений в 1С</label>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
<div class="fld"><label>IP-адрес сервера 1С</label><input type="text" name="client_ips" value="{{$oc.ClientIPs}}" placeholder="10.0.0.15" autocomplete="off" style="margin-top:4px"><span class="muted" style="font-size:12px">Вход в базу bj — только с этого адреса; несколько — через запятую.</span></div>
<div class="fld"><label>Срок решения 1С, минут</label><input type="number" name="timeout_min" min="5" max="1440" value="{{with $oc.TimeoutMin}}{{.}}{{else}}30{{end}}" style="margin-top:4px"><span class="muted" style="font-size:12px">Дольше — оповещение оператору (в рабочее время). Клиенту по сроку не отказываем.</span></div>
</div>
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
{{if $oc.Enabled}}
<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border)">
<h3 style="margin:0 0 6px;font-size:14px">Вход 1С в базу bj</h3>
<p class="muted" style="margin:0 0 8px;font-size:12.5px">bj заводит в PostgreSQL логин <code>onec_bj</code> с правом только читать очередь и записывать решение, открывает вход с IP сервера 1С по TLS.</p>
{{if not $oc.AccessAt.IsZero}}<p style="margin:0 0 8px;font-size:13.5px"><b style="color:var(--{{if $oc.AccessOK}}ok{{else}}err{{end}})">{{if $oc.AccessOK}}✓ Вход открыт{{else}}✗ Вход закрыт{{end}}</b> — {{$oc.AccessMsg}} <span class="muted">({{$oc.AccessAt.Format "02.01.2006 15:04"}})</span></p>{{end}}
{{with .OneCCheck}}{{if .Password}}<div style="margin:0 0 10px;padding:10px 12px;border-radius:9px;background:var(--card-2);font-size:13.5px">
Передайте программисту 1С — пароль показан <b>один раз</b>:<br>
сервер <code>{{.Host}}</code>, порт <code>5432</code>, база <code>bj</code>, логин <code>onec_bj</code>, пароль <code>{{.Password}}</code>, <code>sslmode=require</code>
</div>{{end}}{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap">
<form method="post" action="/admin/setup/onec/access" style="margin:0"><button class="btn" onclick="return confirm('Выдать логину onec_bj новый пароль и открыть вход в базу bj с IP сервера 1С? Прежний пароль перестанет работать. Если PostgreSQL слушал только этот сервер, он перезапустится — это несколько секунд.')">{{if $oc.AccessOK}}Выдать новый пароль{{else}}Открыть вход и выдать пароль{{end}}</button></form>
{{if $oc.AccessOK}}<form method="post" action="/admin/setup/onec/revoke" style="margin:0"><button class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)" onclick="return confirm('Закрыть вход логином onec_bj? 1С перестанет получать поручения, переводы будут ждать.')">Закрыть вход</button></form>{{end}}
</div>
</div>
{{with .OneCCheck}}
<div style="margin-top:16px;padding-top:12px;border-top:1px solid var(--border);font-size:13.5px">
{{if .DBErr}}<div class="alert alert-warn">База bj: {{.DBErr}}</div>{{end}}
<p style="margin:0 0 8px">Ждут решения: <b>{{.Waiting}}</b> · 1С последний раз забирала поручения: <b>{{if .LastRead.IsZero}}ни разу{{else}}{{msk .LastRead}}{{end}}</b></p>
{{if .Rows}}<div style="overflow-x:auto"><table style="font-size:12.5px"><thead><tr><th>Поручение МНП</th><th>Клиент</th><th>Бумага</th><th>Решение</th><th>bj</th></tr></thead><tbody>
{{range .Rows}}<tr><td>{{if .InstrNo}}№{{.InstrNo}}{{else}}—{{end}}{{if eq .Source "terminal"}} <span class="muted">(терминал)</span>{{end}}</td><td>{{.Client}}</td><td><code>{{.ISIN}}</code> × {{.Qty}}</td><td>{{.StatusRu}}{{if .Reason}}: {{.Reason}}{{end}}{{if .CheckedBy}} <span class="muted">— {{.CheckedBy}}</span>{{end}}</td><td>{{.BJStateRu}}</td></tr>{{end}}
</tbody></table></div>{{end}}
</div>
{{end}}
{{end}}
</div>
</section>
{{end}}