Тестирование DEPO: сценарии перевода (клиент и бумаги из DEPO, исход по каждой бумаге) с имитацией ответов НРД, пошаговая запись в FTV_M2M* и показ результата джобы; без отправки куда-либо, кроме базы DEPO; оповещения по тестовым GUID не рассылаются

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-24 12:01:33 +03:00
parent b2f8652381
commit f4f11dd583
9 changed files with 526 additions and 6 deletions
+6 -1
View File
@@ -27,7 +27,7 @@ type admin struct {
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
m2mtest, m2mpreview, depoSim, depoTest *template.Template
journal *template.Template
}
@@ -325,6 +325,10 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_depo_sim: %w", err)
}
depoTest, err := parse("admin_depo_test.html")
if err != nil {
return nil, fmt.Errorf("parse admin_depo_test: %w", err)
}
journal, err := parse("admin_journal.html")
if err != nil {
return nil, fmt.Errorf("parse admin_journal: %w", err)
@@ -339,6 +343,7 @@ func newAdmin() (*admin, error) {
m2mtest: m2mtest,
m2mpreview: m2mpreview,
depoSim: depoSim,
depoTest: depoTest,
journal: journal,
}, nil
}
+1 -1
View File
@@ -889,7 +889,7 @@ func requiredPerm(p, method string) string {
return "dev"
}
switch {
case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard":
case strings.HasPrefix(p, "/admin/setup") || p == "/admin/wizard" || strings.HasPrefix(p, "/admin/depo-test"):
return "settings"
case strings.HasPrefix(p, "/admin/npr"):
return "npr"
+6 -2
View File
@@ -512,7 +512,9 @@ func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
if l != nil && l.DocState != "" && docState != l.DocState {
log.Printf("depo-events: документ DEPO %s по %s: состояние «%s»", doc, shortGUID(guid), docState)
if strings.Contains(strings.ToLower(docState), "отмен") {
raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.")
if !isDepoTestGUID(guid) {
raiseSystemAlert(rc, "DEPO: депозитарий отменил документ по переводу "+shortGUID(guid), "Документ №"+doc+": «"+docState+"». Проверьте перевод и позицию клиента в QUIK.")
}
}
}
}
@@ -527,7 +529,9 @@ func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
x.Errors = append(x.Errors, time.Now().Format("02.01 15:04")+" "+msg)
x.LastEvent = "FTV_M2M.STATE=ERROR"
})
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
if !isDepoTestGUID(guid) {
raiseSystemAlert(rc, "DEPO: джоба не обработала перевод "+shortGUID(guid), msg)
}
}
}
}
+3
View File
@@ -133,6 +133,9 @@ func depoAlertEventFailures(ctx context.Context, db *sql.DB, rc *RuntimeConfig)
return
}
depoFailAlertedID = id.Int64
if isDepoTestGUID(guid) {
return // тестовый прогон DEPO: результат виден на странице теста, сотрудников не оповещаем
}
log.Printf("depo-events: обработчик пометил ERROR событий: %d, последнее %s по %s: %s", n, typ, shortGUID(guid), comment)
raiseSystemAlert(rc, fmt.Sprintf("DEPO: обработчик не смог обработать события (%d)", n),
fmt.Sprintf("Последнее: %s по переводу %s — %s. Устраните причину и верните записи в очередь: Обмен с DEPO → «Вернуть в очередь».", typ, shortGUID(guid), comment))
+418
View File
@@ -0,0 +1,418 @@
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
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
}
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 _, l := range run.Lines {
lines = append(lines, depoLine{Ref: l.Ref, ISIN: l.ISIN, Code: l.ISIN, 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 {
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 {
depoTestFinish(db, id, run.GUID)
return
}
}
}
depoTestFinish(db, id, run.GUID)
}
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
}{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")}
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
}
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)
})
}
+11
View File
@@ -0,0 +1,11 @@
package fsql
// Тестовый режим DEPO: чтение результата обработки трансферных таблиц джобой bj2depo.
var DT01 = `SELECT TOP 1 ISNULL(STATE,''), ISNULL(CAST(DOC AS varchar(32)),''), ISNULL(COMMENT,'') FROM dbo.FTV_M2M WHERE GUID=@g`
var DT02 = `SELECT TOP 1 ISNULL(STATE,''), ISNULL(COMMENT,'') FROM dbo.FTV_M2M_EVENTS
WHERE GUID=@g AND ISNULL(REFERENCE,'')=@r AND EVENT_TYPE=@t ORDER BY ID DESC`
var DT03 = `SELECT ISNULL(REFERENCE,''), ISNULL(ISIN,''), ISNULL(STATE,''), ISNULL(CAST(DOL AS varchar(32)),''), ISNULL(COMMENT,'')
FROM dbo.FTV_M2M_LINES WHERE GUID=@g ORDER BY ID`
+2 -1
View File
@@ -190,7 +190,8 @@ func NewServer(cfg ServerConfig) (*Server, error) {
globalIncomingDeps = &inDeps // автоматический режим M2M
registerIncoming(mux, adminTpl, inDeps)
globalOutgoingStore = NewOutgoingStore()
registerDepoSim(mux, adminTpl) // песочница сверки с DEPO (имитация ответа Fancy)
registerDepoSim(mux, adminTpl)
registerDepoTest(mux, adminTpl) // тестирование DEPO: сценарии с имитацией НРД, только база депозитария // песочница сверки с DEPO (имитация ответа Fancy)
outDeps := outgoingDeps{
store: globalOutgoingStore,
igwClient: igwClient,
@@ -0,0 +1,78 @@
{{define "content"}}
<style>
.dt-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px 14px}
.dt-grid label,.dt-lines label{display:block;font-size:12px;color:var(--muted);margin-bottom:3px}
.dt-grid select,.dt-grid input,.dt-lines select,.dt-lines input{width:100%;padding:7px 9px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text);font-size:13.5px;box-sizing:border-box}
.dt-lines{display:grid;grid-template-columns:2fr 0.8fr 2fr 2fr;gap:8px 10px;margin-top:10px}
.dt-mode{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
.dt-mode label{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:9px;border:1.5px solid var(--border);background:var(--card);cursor:pointer;font-weight:600;font-size:13px}
.dt-mode label:has(input:checked){border-color:var(--accent)}
.st{display:inline-block;padding:1px 8px;border-radius:999px;font-size:12px;font-weight:700}
.st.SUCCESS{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.st.ERROR{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
.st.TODO,.st.running{background:var(--warn-weak,#fff4e0);color:var(--warn,#9a6b12)}
.st.done{background:var(--ok-weak,#e6f4ea);color:var(--ok,#12813f)}
.st.error{background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)}
table.dt{width:100%;border-collapse:collapse;font-size:13px}
table.dt td,table.dt th{padding:5px 8px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
.emu{color:var(--muted);font-size:12px}
@media (max-width:760px){.dt-lines{grid-template-columns:1fr 1fr}}
</style>
{{if .Flash}}<div class="card" style="border-color:var(--warn)">{{.Flash}}</div>{{end}}
<div class="card">
<h2>Тестирование DEPO — сценарии с имитацией НРД</h2>
<p class="muted" style="font-size:13px;margin-top:0">Перевод собирается из реальных данных DEPO и проигрывается по шагам так, как шёл бы в бою: bj пишет записи в трансферные таблицы
<code>FTV_M2M</code> / <code>FTV_M2M_LINES</code> / <code>FTV_M2M_EVENTS</code>, ответы НРД (решение контрагента, приём и исполнение поручения 16/16.1, отказ, истечение срока) имитируются.
После каждого шага bj ждёт, пока джоба обработки DEPO пометит запись, и показывает её STATE и комментарий.
<strong>В НРД, контрагенту, QUIK МНП и ЛК ничего не уходит. Документы в DEPO создаются настоящие</strong> — используйте тестового клиента.</p>
<p class="muted" style="font-size:12px">База: {{.DB}} · обработка DEPO: {{if .JobMode}}{{.JobMode}}{{else}}не настроена{{end}}</p>
<form method="post" action="/admin/depo-test/run">
<div class="dt-mode">
<label><input type="radio" name="direction" value="in" checked> ↘ Зачисление к нам (16/1)</label>
<label><input type="radio" name="direction" value="out"> ↗ Списание от нас (16)</label>
</div>
<div class="dt-grid">
<div><label>Контрагент (справочник участников НРД)</label>
<select name="cp" required>{{range .Parts}}<option value="{{.Code}}" {{if eq .Code "MC0034900000"}}selected{{end}}>{{.Name}} · {{.Code}}</option>{{end}}</select></div>
<div><label>Клиент из DEPO (открытый торговый счёт)</label>
<select name="client" required><option value="">— выбрать —</option>{{range .Clients}}<option value="{{.Name}}|{{.Ser}}|{{.Num}}" {{if eq .Num "289090"}}selected{{end}}>{{.Name}} · {{.Ser}} {{.Num}} · {{.Account}}</option>{{end}}</select></div>
<div><label>Пауза между шагами (имитация задержек НРД)</label>
<select name="pace"><option value="0">без пауз — по готовности джобы</option><option value="5">5 с</option><option value="15">15 с</option><option value="60">1 мин</option></select></div>
<div><label>&nbsp;</label><label style="display:flex;gap:8px;align-items:center;color:var(--text);font-size:13px"><input type="checkbox" name="stop" value="1" checked style="width:auto"> остановиться на первой ошибке джобы</label></div>
</div>
<datalist id="dt-secs">{{range .Secs}}<option value="{{.ISIN}}">{{.Name}}</option>{{end}}</datalist>
<div class="dt-lines">
<label>Бумага (ISIN)</label><label>Кол-во</label><label>Исход (ответ НРД)</label><label>Код отказа (для «отказ по M2M»)</label>
{{$o := .Outcomes}}{{$c := .Codes}}
{{range $i, $n := (slice "1" "2" "3" "4" "5")}}
<input name="isin{{$n}}" list="dt-secs" placeholder="{{if eq $n "1"}}RU0009029540{{else}}— не обязательно —{{end}}" {{if eq $n "1"}}value="RU0009029540"{{end}}>
<input name="qty{{$n}}" value="1" inputmode="decimal">
<select name="out{{$n}}">{{range $o}}<option value="{{.Code}}">{{.Title}}</option>{{end}}</select>
<select name="code{{$n}}"><option value="">—</option>{{range $c}}<option value="{{.Code}}">{{.Title}}</option>{{end}}</select>
{{end}}
</div>
<p class="muted" style="font-size:12px">Разные исходы у разных бумаг = частичное исполнение. «Отказ по M2M» при зачислении — наш отказ (BJ_DECISION_REJECT), при списании — отказ контрагента (BJ_CP_REJECT).</p>
<button type="submit" class="btn btn-ok">Запустить сценарий</button>
</form>
</div>
{{range .Runs}}
<div class="card" id="run-{{.ID}}">
<h2 style="margin-bottom:4px"><span class="st {{.Status}}">{{if eq .Status "running"}}идёт{{else if eq .Status "done"}}завершён{{else}}ошибка{{end}}</span>
{{if eq .Direction "out"}}↗ Списание{{else}}↘ Зачисление{{end}} · {{.Client}} · {{.CPName}} ({{.CP}})</h2>
<p class="muted" style="font-size:12px;margin-top:0">Прогон {{.ID}} · {{.Created.Format "02.01.2006 15:04:05"}} · GUID <code>{{.GUID}}</code> · документ DEPO {{if .Doc}}№ {{.Doc}}{{else}}—{{end}} · шапка <span class="st {{.HeaderState}}">{{.HeaderState}}</span>{{if .Errors}} · <span class="st ERROR">ошибок: {{.Errors}}</span>{{end}}</p>
<table class="dt"><tr><th>Бумага</th><th>Кол-во</th><th>Сценарий</th><th>Строка DEPO</th></tr>
{{range .Lines}}<tr><td>{{.ISIN}}<br><span class="emu">{{.Ref}}</span></td><td>{{.Qty}}</td><td>{{.Outcome}}{{if .Code}} {{.Code}}{{end}}</td><td>{{if .State}}<span class="st {{.State}}">{{.State}}</span> {{if .DOL}}№ {{.DOL}}{{end}} {{.Comment}}{{else}}—{{end}}</td></tr>{{end}}
</table>
<details {{if eq .Status "running"}}open{{end}} style="margin-top:8px"><summary>Шаги ({{len .Steps}})</summary>
<table class="dt"><tr><th>Время</th><th>Шаг</th><th>Событие / строка</th><th>Джоба DEPO</th></tr>
{{range .Steps}}<tr><td>{{.At.Format "15:04:05"}}</td><td>{{.Title}}{{if .Emulated}}<br><span class="emu">имитация — {{.Emulated}}</span>{{end}}</td><td><code>{{.Event}}</code><br><span class="emu">{{.Ref}}</span></td><td>{{if .State}}<span class="st {{.State}}">{{.State}}</span>{{end}} {{.Comment}}</td></tr>{{end}}
</table></details>
</div>
{{end}}
{{if .Running}}<script>setTimeout(function(){location.reload()},4000)</script>{{end}}
{{end}}
+1 -1
View File
@@ -329,7 +329,7 @@ label { font-size:13px; font-weight:500; }
<a href="/admin/incoming">Входящие<span id="nav-incoming-badge" style="display:none;margin-left:5px;background:var(--err);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:700"></span></a>
</span>
</span>
{{end}}{{if can "view"}}<a href="/admin/manual" class="{{if eq .Active "manual"}}active{{end}}">Ручной ввод M2M</a>{{end}}{{if and devOn (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
{{end}}{{if can "view"}}<a href="/admin/manual" class="{{if eq .Active "manual"}}active{{end}}">Ручной ввод M2M</a>{{end}}{{if can "settings"}}<a href="/admin/depo-test" class="{{if eq .Active "depotest"}}active{{end}}">Тест DEPO</a>{{end}}{{if and devOn (can "operate")}}<a href="/admin/m2m-testing" class="{{if eq .Active "m2mtest"}}active{{end}}">Тестирование M2M</a>{{end}}
{{if and .NPR (can "npr")}}<a href="/admin/npr" class="{{if eq .Active "npr"}}active{{end}}">НПР / УДС</a>{{end}}
{{if or (can "view") (can "quik")}}<span class="dd{{if or (eq .Active "fancy") (eq .Active "quik") (eq .Active "news") (eq .Active "handbook")}} active{{end}}">
<span class="ddbtn">Обмен ▾</span>