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
+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
}