21d5f33394
Часть модуля 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
216 lines
8.4 KiB
Go
216 lines
8.4 KiB
Go
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
|
||
}
|