Files
Bridge-and-Join-s/internal/lkgateway/onec_check_test.go
T
fontvielle 21d5f33394 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
2026-10-22 20:13:59 +03:00

216 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}