From 21d5f3339472a23c2d159e3e28766d3a0e01368e Mon Sep 17 00:00:00 2001 From: fontvielle Date: Thu, 22 Oct 2026 20:13:59 +0300 Subject: [PATCH] =?UTF-8?q?1=D0=A1:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=80=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=9C=D0=9D=D0=9F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=20=D0=9D=D0=A0=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Часть модуля 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 Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn --- deploy/linux/sbin/bj-keymedia-worker | 2 + deploy/linux/sbin/bj-onec-admin | 104 ++ internal/lkgateway/admin.go | 7 +- internal/lkgateway/auth.go | 2 +- internal/lkgateway/lktransfer.go | 10 +- internal/lkgateway/onec.go | 2 + internal/lkgateway/onec_check.go | 1157 +++++++++++++++++ internal/lkgateway/onec_check_test.go | 215 +++ internal/lkgateway/server.go | 7 + internal/lkgateway/service.go | 17 + internal/lkgateway/setup.go | 9 + .../lkgateway/web/templates/admin_claim.html | 31 +- .../lkgateway/web/templates/admin_setup.html | 40 +- 13 files changed, 1598 insertions(+), 5 deletions(-) create mode 100755 deploy/linux/sbin/bj-onec-admin create mode 100644 internal/lkgateway/onec_check.go create mode 100644 internal/lkgateway/onec_check_test.go 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)}}
+ {{if and .OneCWaiting (not .SentToNSD)}}
+ ⏳ Перевод ждёт решения 1С.
+ Бумаги заблокированы в QUIK МНП. В НРД перевод уйдёт сам, как только 1С подтвердит поручения; при отказе bj снимет поручения в QUIK с причиной из 1С. Решить вместо 1С — в блоке «Проверка 1С» ниже. + {{else 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}}
Ответ модуля QUIK: {{.QuikFailReply}}{{end}}
{{end}} @@ -114,6 +117,32 @@ {{/* ===== QUIK МНП: поручение «Вывод ЦБ (M2M)» ===== */}} {{$back := printf "/admin/claims/%s" .Claim.ID}} +{{if .OneC}} +
+

Проверка 1С

+ {{range .OneC}} +
+
+ {{.ISIN}}{{if .Qty}} × {{.Qty}}{{end}} + {{if .InstrNo}}поручение МНП №{{.InstrNo}}{{else}}поручения МНП нет — 1С проверить нечего{{end}} + {{.StatusRu}} + bj: {{.BJStateRu}} +
+ {{if .Reason}}
Причина: {{.Reason}}
{{end}} + {{if .CheckedBy}}
{{.CheckedBy}}{{if not .CheckedAt.IsZero}}, {{msk .CheckedAt}}{{end}}{{if .OneCDoc}} · {{.OneCDoc}}{{end}}
{{end}} +
+ {{end}} + {{if and .OneCWaiting (can "operate")}} +
+ + + + +
+

Решение оператора окончательное: 1С по этому поручению уже ничего не изменит.

+ {{end}} +
+{{end}}