Files
Bridge-and-Join-s/internal/lkgateway/depotest.go
T

518 lines
20 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
// depotest.go — режим тестирования DEPO: оператор собирает перевод (клиент и бумаги из DEPO,
// контрагент, исход по каждой бумаге), bj пишет его в трансферные таблицы FTV_M2M* и
// проигрывает события по шагам так, как они шли бы в бою, имитируя ответы НРД (решение
// контрагента, приём и исполнение поручения 16/16.1, отказ НРД, истечение срока). После
// каждого шага ждёт, пока джоба bj2depo обработает запись, и показывает STATE/COMMENT.
// В НРД, контрагенту, QUIK и ЛК ничего не отправляется — только база DEPO.
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
)
const depoTestFile = "/var/lib/bj/.bj/depo-tests.json"
// Исходы строки.
const (
dtExec = "exec" // принято и исполнено
dtDecline = "decline" // отказ по M2M: наш (зачисление) или контрагента (списание)
dtNSD = "nsd" // поручение отклонено НРД (не сквитовано)
dtExpire = "expire" // поручение не исполнено в срок (30 дней)
)
var depoTestOutcomes = []struct{ Code, Title string }{
{dtExec, "исполнено (MS010: исполнено)"},
{dtDecline, "отказ по M2M с кодом"},
{dtNSD, "поручение отклонено НРД (MS010: не сквитовано)"},
{dtExpire, "не исполнено за 30 дней (отмена)"},
}
type DepoTestLine struct {
Ref, ISIN, Qty, Outcome, Code string
Code2 string // код бумаги в НРД, ушедший в SEC_CODE
Gate string // проверка шлюза bj (как в бою): пусто — пройдена, иначе причина
State, DOL, Comment string // результат джобы по строке FTV_M2M_LINES
}
type DepoTestStep struct {
At time.Time
Title, Event, Ref string
State, Comment string // STATE/COMMENT, которые поставила джоба
Emulated string // что «ответил» имитатор НРД (для наглядности)
}
type DepoTestRun struct {
ID, GUID, Direction, CP, CPName string
Client, Ser, Num string
Lines []DepoTestLine
Steps []DepoTestStep
Created time.Time
Status string // running | done | error
Doc, HeaderState, HeaderComment string
Errors int
GateMode string // strict — как в бою | off — без проверки
}
type depoTestStore struct {
mu sync.Mutex
runs []*DepoTestRun
}
var globalDepoTests = loadDepoTests()
func loadDepoTests() *depoTestStore {
s := &depoTestStore{}
if b, err := os.ReadFile(depoTestFile); err == nil {
_ = json.Unmarshal(b, &s.runs)
}
for _, r := range s.runs {
if r.Status == "running" {
r.Status = "error"
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: "прогон прерван перезапуском bj"})
}
}
return s
}
func (s *depoTestStore) saveLocked() {
if len(s.runs) > 200 {
s.runs = s.runs[len(s.runs)-200:]
}
b, _ := json.MarshalIndent(s.runs, "", " ")
tmp := depoTestFile + ".tmp"
if os.WriteFile(tmp, b, 0o600) == nil {
_ = os.Rename(tmp, depoTestFile)
}
}
func (s *depoTestStore) update(id string, fn func(r *DepoTestRun)) {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.runs {
if r.ID == id {
fn(r)
}
}
s.saveLocked()
}
func (s *depoTestStore) list() []DepoTestRun {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]DepoTestRun, 0, len(s.runs))
for i := len(s.runs) - 1; i >= 0; i-- {
r := *s.runs[i]
r.Steps = append([]DepoTestStep(nil), r.Steps...)
r.Lines = append([]DepoTestLine(nil), r.Lines...)
out = append(out, r)
}
return out
}
// isDepoTestGUID — перевод создан тестовым режимом: по нему не шлём оповещений сотрудникам.
func isDepoTestGUID(guid string) bool {
g := strings.ToUpper(strings.TrimSpace(guid))
globalDepoTests.mu.Lock()
defer globalDepoTests.mu.Unlock()
for _, r := range globalDepoTests.runs {
if r.GUID == g {
return true
}
}
return false
}
func newDepoGUID() string {
n := time.Now()
return strings.ToUpper(fmt.Sprintf("%08x-%04x-4000-8000-%012x", n.Unix()&0xFFFFFFFF, n.UnixNano()&0xFFFF, n.UnixNano()&0xFFFFFFFFFFFF))
}
// depoTestExec — проигрывание сценария. pace — пауза между шагами (имитация задержек НРД).
func depoTestExec(rc *RuntimeConfig, run *DepoTestRun, pace time.Duration, stopOnError bool) {
id := run.ID
fail := func(msg string) {
globalDepoTests.update(id, func(r *DepoTestRun) {
r.Status = "error"
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: msg, State: "ERROR"})
})
}
db, err := depoEventsDB(rc)
if err != nil {
fail("нет подключения к DEPO: " + err.Error())
return
}
defer db.Close()
// 1. шапка и строки
place := rc.Snapshot().NSD.SectionID
var lines []depoLine
for i, l := range run.Lines {
// SEC_CODE — код бумаги в НРД (как в настоящем пакете M2M): по ISIN+коду джоба находит бумагу в DEPO
code := depoNSDCode(db, l.ISIN)
if code == "" {
code = l.ISIN
globalDepoTests.update(id, func(r *DepoTestRun) {
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: "Бумага " + l.ISIN + ": кода НРД (кодировщик «НДЦ») в DEPO нет — SEC_CODE = ISIN, джоба может не найти бумагу", State: "WARN"})
})
}
globalDepoTests.update(id, func(r *DepoTestRun) { r.Lines[i].Code2 = code })
lines = append(lines, depoLine{Ref: l.Ref, ISIN: l.ISIN, Code: code, Amount: parseQty(l.Qty), Place: place})
}
dir := "IN"
if run.Direction == "out" {
dir = "OUT"
}
if !depoUpsertOrder(rc, run.GUID, dir, run.CP, run.Client, strings.TrimSpace(run.Ser+" "+run.Num), run.Ser, run.Num, lines, "ТЕСТ DEPO "+run.ID) {
fail("не удалось записать шапку и строки в FTV_M2M (журнал bj-server)")
return
}
state, doc, comment := depoTestWaitHeader(db, run.GUID)
globalDepoTests.update(id, func(r *DepoTestRun) {
r.Doc, r.HeaderState, r.HeaderComment = doc, state, comment
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: "Перевод записан в FTV_M2M / FTV_M2M_LINES → документ DEPO", Event: "FTV_M2M", State: state, Comment: pick(comment, "документ № "+doc)})
if state == "ERROR" {
r.Errors++
}
})
if state == "ERROR" && stopOnError {
globalDepoTests.update(id, func(r *DepoTestRun) { r.Status = "stopped" })
depoTestFinish(db, id, run.GUID)
return
}
// 2. события по шагам
type ev struct{ title, typ, codes, params, emu string }
stepsFor := func(l DepoTestLine) []ev {
var s []ev
if run.Direction == "out" {
s = append(s, ev{"Отправили M2MTR в НРД", "BJ_SENT", "", "", ""})
if l.Outcome == dtDecline {
return append(s, ev{"Контрагент отказал (M2MTD)", "BJ_CP_REJECT", l.Code, m2mCodeLabel(l.Code), "НРД: решение контрагента — отказ " + l.Code})
}
s = append(s, ev{"Контрагент подтвердил (M2MTD)", "BJ_CP_CONFIRM", "", "", "НРД: решение контрагента — подтверждение"})
} else {
s = append(s, ev{"Получили M2MTR от контрагента", "BJ_RECEIVED", "", "", "НРД: входящий M2MTR"})
if l.Outcome == dtDecline {
return append(s, ev{"Наше решение: отказ (M2MTD)", "BJ_DECISION_REJECT", l.Code, m2mCodeLabel(l.Code), ""})
}
s = append(s, ev{"Наше решение: подтверждение (M2MTD)", "BJ_DECISION_CONFIRM", "", "", ""})
}
form := "16/1"
if run.Direction == "out" {
form = "16"
}
s = append(s, ev{"Подали поручение " + form + " в НРД", "BJ_ORDER_SENT", "", "EMU-" + l.Ref, "НРД: поручение принято к обработке"})
switch l.Outcome {
case dtNSD:
s = append(s, ev{"НРД отклонил поручение (MS010)", "BJ_ORDER_REJECTED", "", "GS116 — поручение не сквитовано со встречным поручением контрагента (имитация НРД)", "НРД: MS010 — отказ, GS116"})
case dtExpire:
s = append(s, ev{"Нет ответа НРД в срок (просрочка)", "BJ_ORDER_LATE", "", "", "НРД: MS010 не пришёл"},
ev{"30 дней истекли — поручение отменено", "BJ_CANCELLED", "", "", "НРД: срок исполнения истёк"})
default:
s = append(s, ev{"НРД принял поручение (MS010)", "BJ_ORDER_ACCEPTED", "", "", "НРД: MS010 — принято"},
ev{"НРД исполнил поручение (MS010)", "BJ_EXECUTED", "", "", "НРД: MS010 — исполнено"})
}
return s
}
// порядок как в бою: сначала общий этап по всем строкам, затем исходы по строкам
var queue [][]ev
maxLen := 0
for _, l := range run.Lines {
st := stepsFor(l)
queue = append(queue, st)
if len(st) > maxLen {
maxLen = len(st)
}
}
for k := 0; k < maxLen; k++ {
for i, l := range run.Lines {
if k >= len(queue[i]) {
continue
}
e := queue[i][k]
if pace > 0 {
time.Sleep(pace)
}
depoEventAt(rc, time.Now(), run.GUID, l.Ref, e.typ, e.codes, e.params)
st, cm := depoTestWaitEvent(db, run.GUID, l.Ref, e.typ)
globalDepoTests.update(id, func(r *DepoTestRun) {
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: e.title, Event: e.typ, Ref: l.Ref + " " + l.ISIN, State: st, Comment: cm, Emulated: e.emu})
if st == "ERROR" || st == "" {
r.Errors++
}
})
if st == "ERROR" && stopOnError {
globalDepoTests.update(id, func(r *DepoTestRun) {
r.Status = "stopped"
r.Steps = append(r.Steps, DepoTestStep{At: time.Now(), Title: "Сценарий остановлен на ошибке джобы (галочка «остановиться на первой ошибке»): оставшиеся шаги не записаны", State: "WARN"})
})
depoTestFinish(db, id, run.GUID)
return
}
}
}
depoTestFinish(db, id, run.GUID)
}
// depoNSDCode — код бумаги в НРД из DEPO (кодировщик «НДЦ»).
func depoNSDCode(db *sql.DB, isin string) string {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var c string
_ = db.QueryRowContext(ctx, fsql.DT04, sql.Named("i", strings.ToUpper(strings.TrimSpace(isin)))).Scan(&c)
return strings.TrimSpace(c)
}
func depoTestFinish(db *sql.DB, id, guid string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
res := map[string]DepoTestLine{}
if rows, err := db.QueryContext(ctx, fsql.DT03, sql.Named("g", guid)); err == nil {
for rows.Next() {
var l DepoTestLine
if rows.Scan(&l.Ref, &l.ISIN, &l.State, &l.DOL, &l.Comment) == nil {
res[l.Ref] = l
}
}
rows.Close()
}
var st, doc, cm string
_ = db.QueryRowContext(ctx, fsql.DT01, sql.Named("g", guid)).Scan(&st, &doc, &cm)
globalDepoTests.update(id, func(r *DepoTestRun) {
for i := range r.Lines {
if x, ok := res[r.Lines[i].Ref]; ok {
r.Lines[i].State, r.Lines[i].DOL, r.Lines[i].Comment = x.State, x.DOL, x.Comment
}
}
r.HeaderState, r.Doc, r.HeaderComment = st, doc, cm
if r.Status == "running" {
r.Status = "done"
}
})
log.Printf("depo-test: прогон %s завершён (GUID %s)", id, shortGUID(guid))
}
func depoTestWaitHeader(db *sql.DB, guid string) (state, doc, comment string) {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := db.QueryRowContext(ctx, fsql.DT01, sql.Named("g", guid)).Scan(&state, &doc, &comment)
cancel()
if err == nil && state != "" && state != "TODO" && state != "WORK" {
return
}
time.Sleep(2 * time.Second)
}
return pick(state, "TODO"), doc, "джоба не обработала шапку за 90 с — проверьте, что обработка DEPO запущена"
}
func depoTestWaitEvent(db *sql.DB, guid, ref, typ string) (state, comment string) {
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := db.QueryRowContext(ctx, fsql.DT02, sql.Named("g", guid), sql.Named("r", ref), sql.Named("t", typ)).Scan(&state, &comment)
cancel()
if err == nil && state != "" && state != "TODO" && state != "WORK" {
return
}
time.Sleep(2 * time.Second)
}
return pick(state, "TODO"), "джоба не обработала событие за 90 с"
}
// registerDepoTest — /admin/depo-test: форма, список прогонов; POST /admin/depo-test/run — запуск.
func registerDepoTest(mux *http.ServeMux, a *admin) {
mux.HandleFunc("/admin/depo-test", func(w http.ResponseWriter, r *http.Request) {
clients, secs := depoSimLists()
var parts []HandbookEntry
for _, p := range handbookParticipants() {
if p.Code != "" {
parts = append(parts, p)
}
}
sort.Slice(parts, func(i, j int) bool { return parts[i].Name < parts[j].Name })
var codes []struct{ Code, Title string }
for i := 1; i <= 9; i++ {
c := fmt.Sprintf("M2M%02d", i)
codes = append(codes, struct{ Code, Title string }{c, c + " — " + m2mCodeLabel(c)})
}
runs := globalDepoTests.list()
running := false
for _, x := range runs {
if x.Status == "running" {
running = true
}
}
fs := globalRC.Snapshot().Fancy
data := struct {
page
Clients []depoSimClient
Secs []depoSimSec
Parts []HandbookEntry
Codes []struct{ Code, Title string }
Outcomes []struct{ Code, Title string }
Runs []DepoTestRun
Running bool
DB string
JobMode string
Flash string
Rows []string
}{nowPage("Тестирование DEPO", "depotest"), clients, secs, parts, codes,
append([]struct{ Code, Title string }(nil), depoTestOutcomes...), runs, running,
fs.Server + " / " + fs.DepoDB(), fs.DepoJobMode, r.URL.Query().Get("flash"), []string{"1", "2", "3", "4", "5"}}
render(w, a.depoTest, data)
})
mux.HandleFunc("/admin/depo-test/run", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
back := func(msg string) {
http.Redirect(w, r, "/admin/depo-test?flash="+urlQ(msg), http.StatusSeeOther)
}
dir := r.FormValue("direction")
if dir != "out" {
dir = "in"
}
cp := strings.TrimSpace(r.FormValue("cp"))
client := strings.TrimSpace(r.FormValue("client")) // «ФИО|серия|номер»
p := strings.Split(client, "|")
if len(p) != 3 || p[0] == "" || p[2] == "" {
back("Выберите клиента из DEPO")
return
}
if cp == "" {
back("Выберите контрагента")
return
}
run := &DepoTestRun{ID: time.Now().Format("0102-150405"), GUID: newDepoGUID(), Direction: dir, CP: cp, CPName: handbookNameByCode(cp),
Client: p[0], Ser: p[1], Num: p[2], Created: time.Now(), Status: "running"}
base := time.Now().UnixNano() / 1e6 % 1e10
for i := 1; i <= 5; i++ {
isin := strings.ToUpper(strings.TrimSpace(r.FormValue("isin" + strconv.Itoa(i))))
if isin == "" {
continue
}
l := DepoTestLine{Ref: fmt.Sprintf("M2MT%010d%02d", base, i), ISIN: isin, Qty: pick(strings.TrimSpace(r.FormValue("qty"+strconv.Itoa(i))), "1"),
Outcome: r.FormValue("out" + strconv.Itoa(i)), Code: r.FormValue("code" + strconv.Itoa(i))}
switch l.Outcome {
case dtExec, dtNSD, dtExpire:
l.Code = ""
case dtDecline:
if l.Code == "" {
l.Code = "M2M05"
}
default:
l.Outcome = dtExec
}
run.Lines = append(run.Lines, l)
}
if len(run.Lines) == 0 {
back("Добавьте хотя бы одну бумагу")
return
}
// Проверка шлюза bj — та же сверка с DEPO, что перед настоящей отправкой/решением.
run.GateMode = "strict"
if r.FormValue("gate") == "off" {
run.GateMode = "off"
}
gate := depoTestGate(run)
var blocked []string
for i := range run.Lines {
l := &run.Lines[i]
g, ok := gate[l.Ref]
if !ok || g.ok {
continue
}
l.Gate = g.text
if run.GateMode != "strict" || l.Outcome == dtDecline {
continue
}
if dir == "in" {
// как автоматический режим: отказ с кодом, который дала сверка
l.Outcome, l.Code = dtDecline, pick(g.code, "M2M05")
} else {
blocked = append(blocked, l.ISIN+": "+g.text)
}
}
if len(blocked) > 0 {
back("В бою bj не отправил бы этот перевод — сверка списания с DEPO не пройдена: " + strings.Join(blocked, "; ") +
". Уберите бумагу или выберите «без проверки шлюза», если нужно проверить именно джобу DEPO.")
return
}
pace, _ := strconv.Atoi(r.FormValue("pace"))
stop := r.FormValue("stop") == "1"
globalDepoTests.mu.Lock()
globalDepoTests.runs = append(globalDepoTests.runs, run)
globalDepoTests.saveLocked()
globalDepoTests.mu.Unlock()
cpy := *run
cpy.Lines = append([]DepoTestLine(nil), run.Lines...)
go depoTestExec(globalRC, &cpy, time.Duration(pace)*time.Second, stop)
log.Printf("depo-test: запущен прогон %s (%s, %s, %d бумаг) пользователем %s", run.ID, dir, cp, len(run.Lines), func() string {
if u := currentUser(w); u != nil {
return u.Login
}
return "-"
}())
http.Redirect(w, r, "/admin/depo-test#run-"+run.ID, http.StatusSeeOther)
})
}
type depoGateLine struct {
ok bool
code, text string
}
// depoTestGate — сверка с DEPO для строк сценария (runDepoCheck / runDepoCheckOut, как в бою).
func depoTestGate(run *DepoTestRun) map[string]depoGateLine {
out := map[string]depoGateLine{}
fio := strings.Fields(run.Client)
for len(fio) < 3 {
fio = append(fio, "")
}
var res depoResult
if run.Direction == "out" {
var secs []depoOutSec
for _, l := range run.Lines {
secs = append(secs, depoOutSec{ReferenceID: l.Ref, ISIN: l.ISIN, Code: l.ISIN, Qty: l.Qty})
}
nsd := globalRC.Snapshot().NSD
res = runDepoCheckOut(fio[0], fio[1], strings.Join(fio[2:], " "), run.Ser, run.Num, secs, nsd.AccountID, nsd.SectionID)
} else {
it := &IncomingTransfer{GUID: run.GUID, SenderCode: run.CP, ReceivedAt: time.Now(),
InvestorLast: fio[0], InvestorFirst: fio[1], InvestorMiddle: strings.Join(fio[2:], " "),
DocType: "21", DocSeries: run.Ser, DocNumber: run.Num}
for _, l := range run.Lines {
it.Securities = append(it.Securities, IncomingSecurity{ReferenceID: l.Ref, ISIN: l.ISIN, SecurityCode: l.ISIN, Quantity: l.Qty})
}
res = runDepoCheck(it)
}
for _, s := range res.Secs {
g := depoGateLine{ok: s.Recommend != "reject", code: s.RejectCode}
var bad []string
for _, c := range s.Checks {
if !c.OK {
bad = append(bad, strings.TrimSpace(c.Name+" "+c.Detail))
}
}
g.text = strings.Join(bad, "; ")
out[s.ReferenceID] = g
}
return out
}