Files
Bridge-and-Join-s/internal/lkgateway/m2mtest.go
T
zuevav da12a9ec1c feat(bj): модуль ЛК на PostgreSQL, обмен с DEPO, боевой ЛК с имитатором НРД, QUIK, НПР
- bj_exchange и «кран» Fancy убраны: данные модуля ЛК в PostgreSQL (схема lk)
- трансферные таблицы FTV_M2M: создание при установке, события, справочник участников
- сверка с DEPO: счёт, ФИО, дата рождения, ИИС по договору, квал-инвестор из опции DEPO
- боевой ЛК ЕСИА Финанс при тестовом ИШ: журнал, отказ через DEPO, статусы в ЛК
- онлайн-лицензирование в bj, план тестирования M2M для контрагента (интерактивный HTML)
- модули QUIK МНП, экспорт QUIK, НПР/УДС; документы и тестовые материалы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:12 +03:00

893 lines
37 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
// m2mtest.go — интерактивный блок «Тестирование M2M» с реальным контрагентом
// (МБ/НРД). Мы — Контрагент 2. Страница /admin/m2m-testing:
// • список всех кейсов из «Сценарий тестирования (приложение 2)» с визуальным
// статусом (не проведён / в процессе / успех / провал);
// • по кейсам, где мы Списывающий, — кнопка «Инициировать отправку» (шлём
// исходящий M2M контрагенту через НРД);
// • по кейсам, где мы Зачисляющий, — подсказка отвечать на «Входящих» (с кодом
// отказа для негативных кейсов).
// Статусы и код контрагента хранятся в /var/lib/bj/.bj/m2m-testplan.json.
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
const m2mTestPlanFile = "/var/lib/bj/.bj/m2m-testplan.json"
// m2mSec — бумага в кейсе.
type m2mSec struct {
ISIN, Code, Qty string
Frac bool // пай — количество в дробном поле
}
// m2mClient — тестовый клиент (физлицо). SeedID — id в сид-стороне, по которому
// EnrichRequest соберёт СОГЛАСОВАННЫЕ ФИО+документ+счёт (реальный депозитарий).
type m2mClient struct{ Last, First, Middle, DocSeries, DocNumber, SeedID string }
var m2mTestClients = map[string]m2mClient{
"К1": {"Мостовиков", "Арсений", "Тимурович", "4519", "736284", seedClientK1},
"К2": {"Баранкин", "Сергей", "Сергеевич", "3763", "290089", seedClientK2},
}
// эталонные бумаги теста (кол-во — как в сценарии; для нескольких выпусков 10/5/20)
var (
sSBER = func(q string) m2mSec { return m2mSec{"RU0009029540", "SBER/03", q, false} }
sPAI = func(q string) m2mSec { return m2mSec{"RU000A0JUR61", "RU000A0JUR61", q, true} }
sVTB = func(q string) m2mSec { return m2mSec{"RU000A102QL3", "RU000A102QL3", q, false} }
sGAZP = func(q string) m2mSec { return m2mSec{"RU0007661625", "RU0007661625", q, false} }
sMINF = func(q string) m2mSec { return m2mSec{"XS0088543193", "XS0088543193", q, false} }
)
// m2mSecName — наименования тест-бумаг (для превью отправляемых данных).
var m2mSecName = map[string]string{
"RU0009029540": "Акции об. ПАО Сбербанк",
"RU000A0JUR61": "Пай ИПИФ «КОНСЕРВАТОРия»",
"RU000A102QL3": "Облигация суборд. ВТБ (СУБ-Т1-2)",
"RU0007661625": "Акции об. ПАО «Газпром»",
"XS0088543193": "Минфин РФ (USD)",
}
// m2mCaseDefect — какой реквизит намеренно «неправильный» в кейсе и почему.
// field: "" (позитив, всё верно) | "security" | "account" | "investor" | "other".
func m2mCaseDefect(c m2mCase) (field, desc string) {
switch c.Expect {
case "":
return "", ""
case "M2M01":
return "security", "Бумага не допускается к M2M (не-российский ISIN) — сервис МОСТ отклонит запрос."
case "M2M02":
return "account", "Счёт зачисления не может быть определён зачисляющим — отклонение."
case "M2M03":
return "account", "Счёт депо зачисления не торговый — зачисляющий отклонит."
case "M2M04":
return "investor", "Инвестор (Клиент 2, Баранкин) не найден у зачисляющего депозитария — отклонение."
case "M2M05":
return "security", "Зачисляющий не обслуживает бумагу (Газпром) — отклонение."
case "M2M06":
return "account", "Приостановление операций по счёту депо зачисления — отклонение."
case "M2M08":
return "security", "Бумага для квалинвесторов — операция нарушит закон РФ, отклонение."
}
return "other", "Ожидается ответ сервиса МОСТ с кодом " + c.Expect + " (см. план)."
}
// m2mSecBad — является ли конкретная бумага той самой «неправильной» в кейсе.
func m2mSecBad(c m2mCase, s m2mSec) bool {
switch c.Expect {
case "M2M01":
return !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(s.ISIN)), "RU")
case "M2M05":
return s.ISIN == "RU0007661625"
case "M2M08":
return true
}
return false
}
// m2mCase — один кейс тест-плана.
type m2mCase struct {
ID string
Scenario string // "1" | "1-СПБ" | "2"
Debit string // Списывающий
Credit string // Зачисляющий
Client string // "К1"|"К2"
SettleDeb string // "НРД"|"СПБ"
SettleCred string
Secs []m2mSec
Expect string // код отказа/информирования или "" (успех)
Note string
}
// m2mOurRole — наша роль в кейсе (мы = Контрагент 2).
func m2mOurRole(c m2mCase) string {
switch "Контрагент 2" {
case c.Debit:
return "Списывающий (мы)"
case c.Credit:
return "Зачисляющий (мы)"
}
return "—"
}
// m2mWeInitiate — по кейсу инициируем отправку мы (мы Списывающий).
func m2mWeInitiate(c m2mCase) bool { return c.Debit == "Контрагент 2" }
// m2mCases — весь тест-план (Приложение 2).
var m2mCases = buildM2MCases()
func buildM2MCases() []m2mCase {
K1, K2 := "Контрагент 1", "Контрагент 2"
var cs []m2mCase
add := func(id, deb, cred, cl, sd, sc string, secs []m2mSec, exp, note string) {
cs = append(cs, m2mCase{id, curScenario, deb, cred, cl, sd, sc, secs, exp, note})
}
// --- Сценарий 1: успешные + отказы Зачисляющего ---
curScenario = "1"
add("1", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "", "Успех, 1 выпуск")
add("2", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "", "Успех, 1 выпуск")
add("3", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sPAI("5"), sVTB("20")}, "", "Успех, 3 выпуска")
add("4", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sPAI("5"), sVTB("20")}, "", "Успех, 3 выпуска")
add("5", K1, K2, "К1", "НРД", "НРД", []m2mSec{sMINF("1")}, "M2M01", "ЦБ не допускается к M2M (не-RU)")
add("6", K2, K1, "К1", "НРД", "НРД", []m2mSec{sMINF("1")}, "M2M01", "ЦБ не допускается к M2M (не-RU)")
add("7", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M02", "Не определён счёт зачисления")
add("8", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M02", "Не определён счёт зачисления")
add("9", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M03", "Счёт депо зачисления не торговый")
add("10", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M03", "Счёт депо зачисления не торговый")
add("11", K1, K2, "К2", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M04", "Инвестор не найден (Клиент 2)")
add("12", K2, K1, "К2", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M04", "Инвестор не найден (Клиент 2)")
add("13", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sGAZP("5")}, "M2M05", "Не обслуживаем бумагу (Газпром)")
add("14", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10"), sGAZP("5")}, "M2M05", "Не обслуживаем бумагу (Газпром)")
add("15", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M06", "Приостановление операций по счёту")
add("16", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M06", "Приостановление операций по счёту")
add("17", K1, K2, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M08", "Нарушение закона РФ (квалинвестор)")
add("18", K2, K1, "К1", "НРД", "НРД", []m2mSec{sSBER("10")}, "M2M08", "Нарушение закона РФ (квалинвестор)")
// --- Сценарий 1 (СПБ): разные расчётные депозитарии ---
curScenario = "1-СПБ"
add("СПБ-9", K1, K2, "К1", "СПБ", "НРД", []m2mSec{sSBER("10")}, "", "СПБ→НРД")
add("СПБ-10", K2, K1, "К1", "НРД", "СПБ", []m2mSec{sSBER("10")}, "", "НРД→СПБ")
add("СПБ-11", K1, K2, "К1", "НРД", "СПБ", []m2mSec{sSBER("10")}, "", "НРД→СПБ")
add("СПБ-12", K2, K1, "К1", "СПБ", "НРД", []m2mSec{sSBER("10")}, "", "СПБ→НРД")
add("СПБ-13", K1, K2, "К1", "СПБ", "СПБ", []m2mSec{sSBER("10")}, "", "СПБ→СПБ")
add("СПБ-14", K2, K1, "К2", "СПБ", "СПБ", []m2mSec{sSBER("10")}, "", "СПБ→СПБ, Клиент 2")
// --- Сценарий 2: коды ошибок/информирования сервиса МОСТ (чек-лист) ---
curScenario = "2"
add("2-ERR-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M10-15,17-19,21-24,26-43", "Как Списывающий: ERROR на M2MTransferRequest")
add("2-ERR-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M10-15,17-19,21-24,26-43", "Как Зачисляющий: ERROR на M2MTransferDecision")
add("2-INFO16-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M16", "INFO: блок «Информация от сервиса МОСТ» — Списывающий")
add("2-INFO16-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M16", "INFO: блок «Информация от сервиса МОСТ» — Зачисляющий")
add("2-INFO-deb", K2, K1, "К1", "НРД", "НРД", nil, "M2M20,25,41", "INFO с блоком «Информация от сервиса МОСТ» — Списывающий")
add("2-INFO-cred", K1, K2, "К1", "НРД", "НРД", nil, "M2M20,25,41", "INFO с блоком «Информация от сервиса МОСТ» — Зачисляющий")
add("2-FORM", K2, K1, "К1", "НРД", "НРД", nil, "M2M37-40", "Анкета участника (M2MTransferParticipantForm): ERROR")
return cs
}
var curScenario string // временная переменная-помощник для buildM2MCases
// ---- хранилище статусов ----
type m2mCaseState struct {
Status string `json:"status"` // "" | in_progress | passed | failed
Note string `json:"note,omitempty"`
GUID string `json:"guid,omitempty"`
Updated time.Time `json:"updated,omitempty"`
}
type m2mPlan struct {
CounterpartyName string `json:"counterparty_name"` // напр. «Банк ЗЕНИТ»
CounterpartyCode string `json:"counterparty_code"` // код Контрагента 1
CounterpartyINN string `json:"counterparty_inn"` // ИНН депозитария контрагента
OurINN string `json:"our_inn"` // ИНН нашего депозитария
States map[string]m2mCaseState `json:"states"`
}
// m2mExcluded — кейсы, которые в этом прогоне НЕ тестируем (по договорённости
// с контрагентом). Отображаются серым, в прогресс не входят.
var m2mExcluded = map[string]bool{} // в прогоне с Банком ЗЕНИТ (31.08.2026) исключались 3 и 4
var m2mMu sync.Mutex
func m2mLoadPlan() m2mPlan {
p := m2mPlan{States: map[string]m2mCaseState{}}
b, err := os.ReadFile(m2mTestPlanFile)
if err == nil {
_ = json.Unmarshal(b, &p)
}
if p.States == nil {
p.States = map[string]m2mCaseState{}
}
return p
}
func m2mSavePlan(p m2mPlan) error {
b, _ := json.MarshalIndent(p, "", " ")
tmp := m2mTestPlanFile + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, m2mTestPlanFile)
}
// ---- HTTP ----
// registerM2MTest вешает маршруты интерактивного тест-плана.
func registerM2MTest(mux *http.ServeMux, a *admin, h *setupHandlers) {
mux.HandleFunc("/admin/m2m-testing", func(w http.ResponseWriter, r *http.Request) {
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
type rowVM struct {
m2mCase
OurRole string
WeInitiate bool
SecList string
State m2mCaseState
StatusClass string
Live string // авто-исход из НРД: "Ждём ответ" / "НРД: confirmed" / "НРД: rejected (M2M02)"
Excluded bool
Desc string // описание кейса для подсказки при наведении
}
ctx := r.Context()
planChanged := false
var rows []rowVM
var passed, failed, prog, total, excluded int
for _, c := range m2mCases {
var secparts []string
for _, s := range c.Secs {
secparts = append(secparts, s.Code+" ×"+s.Qty)
}
st := plan.States[c.ID]
live := ""
if m2mExcluded[c.ID] || c.Scenario == "1-СПБ" {
excluded++
rows = append(rows, rowVM{
m2mCase: c, OurRole: m2mOurRole(c), WeInitiate: m2mWeInitiate(c),
SecList: strings.Join(secparts, ", "), State: st, StatusClass: "excluded", Excluded: true,
Desc: m2mCaseDesc(c),
})
continue
}
// Автоподтягивание по GUID (кейсы, где мы инициировали отправку).
if st.GUID != "" {
if outcome, codes, terminal, ok := h.m2mLiveOutcome(ctx, st.GUID); ok {
if terminal {
live = "НРД: " + outcome
if len(codes) > 0 {
live += " (" + strings.Join(codes, ",") + ")"
}
// авто-вердикт против ожидаемого — только если не отмечено вручную
if st.Status == "" || st.Status == "in_progress" {
want := autoVerdict(c.Expect, outcome, codes)
if st.Status != want {
st.Status = want
st.Note = "Авто из НРД: " + live
st.Updated = time.Now()
plan.States[c.ID] = st
planChanged = true
}
}
} else {
live = "Ждём ответ НРД"
if st.Status == "" {
st.Status = "in_progress"
plan.States[c.ID] = st
planChanged = true
}
}
}
}
total++
switch st.Status {
case "passed":
passed++
case "failed":
failed++
case "in_progress":
prog++
}
rows = append(rows, rowVM{
m2mCase: c, OurRole: m2mOurRole(c), WeInitiate: m2mWeInitiate(c),
SecList: strings.Join(secparts, ", "), State: st, StatusClass: st.Status, Live: live,
Desc: m2mCaseDesc(c),
})
}
if planChanged {
m2mMu.Lock()
// перечитываем и мержим, чтобы не затереть параллельные ручные правки
cur := m2mLoadPlan()
for id, s := range plan.States {
if s.GUID != "" { // авто трогает только инициированные кейсы
cur.States[id] = s
}
}
_ = m2mSavePlan(cur)
m2mMu.Unlock()
}
// Наш депозитарий — код из настроек НРД, ИНН/имя тянем из справочника.
ourCode := h.rc.Snapshot().NSD.DeponentCode
ourINN := plan.OurINN
if ourINN == "" {
ourINN = m2mINNByCode(ourCode)
}
data := struct {
page
Rows []rowVM
CounterpartyName string
CounterpartyCode string
CounterpartyINN string
OurCode string
OurName string
OurINN string
Passed, Failed, Prog, Total, Excluded int
Pending int
}{
page: nowPage("Тестирование M2M", "m2mtest"), Rows: rows,
CounterpartyName: plan.CounterpartyName, CounterpartyCode: plan.CounterpartyCode,
CounterpartyINN: plan.CounterpartyINN,
OurCode: ourCode, OurName: m2mPartyName(ourCode), OurINN: ourINN,
Passed: passed, Failed: failed, Prog: prog, Total: total, Excluded: excluded,
Pending: total - passed - failed - prog - excluded,
}
render(w, a.m2mtest, data)
})
// Превью отправляемых данных с подсветкой: 🟢 корректно, 🔴 намеренно неверно.
mux.HandleFunc("/admin/m2m-testing/preview", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
var c *m2mCase
for i := range m2mCases {
if id != "" && m2mCases[i].ID == id {
c = &m2mCases[i]
break
}
}
if c == nil {
// ВАЖНО: один чистый редирект на план (без setupFlash, который вернул бы
// на Referer=/preview и создал петлю редиректов → «страница недоступна»).
http.Redirect(w, r, "/admin/m2m-testing?flash="+
url.QueryEscape("Кейс не выбран или не найден — выберите кейс в плане и нажмите «Инициировать»."),
http.StatusSeeOther)
return
}
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
cl := m2mTestClients[c.Client]
st := plan.States[c.ID]
alreadySent := st.GUID != "" // по кейсу уже уходил запрос (защита от дубля)
nsd := h.rc.Snapshot().NSD
defField, defDesc := m2mCaseDefect(*c)
type pvSec struct {
ISIN, Code, Name, Qty string
Bad bool
}
var secs []pvSec
for _, s := range c.Secs {
secs = append(secs, pvSec{
ISIN: s.ISIN, Code: s.Code, Name: m2mSecName[s.ISIN], Qty: s.Qty,
Bad: m2mSecBad(*c, s),
})
}
ourCode := nsd.DeponentCode
ourINN := plan.OurINN
if ourINN == "" {
ourINN = m2mINNByCode(ourCode)
}
data := struct {
page
Case m2mCase
WeInitiate bool
Positive bool
DefectField string
DefectDesc string
ClientKey string
ClientFIO string
ClientDUL string
ClientBad bool
OurCode string
OurName string
OurINN string
OurAccount string
CpCode string
CpName string
CpINN string
Secs []pvSec
AlreadySent bool
SentGUID string
SentWhen string
Depo depoResult // сверка списания с DEPO (эмуляция; только для кейсов, где мы списывающий)
}{
page: nowPage("Проверка данных перед отправкой", "m2mtest"),
Case: *c,
AlreadySent: alreadySent,
SentGUID: st.GUID,
SentWhen: st.Updated.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05"),
Depo: func() depoResult {
if !m2mWeInitiate(*c) {
return depoResult{}
}
var ss []depoOutSec
for _, s := range c.Secs {
ss = append(ss, depoOutSec{ReferenceID: s.Code, ISIN: s.ISIN, Code: s.Code, Qty: s.Qty})
}
return runDepoCheckOut(cl.Last, cl.First, cl.Middle, cl.DocSeries, cl.DocNumber, ss, nsd.AccountID, nsd.SectionID)
}(),
WeInitiate: m2mWeInitiate(*c),
Positive: defField == "",
DefectField: defField,
DefectDesc: defDesc,
ClientKey: c.Client,
ClientFIO: strings.TrimSpace(cl.Last + " " + cl.First + " " + cl.Middle),
ClientDUL: "паспорт РФ " + cl.DocSeries + " № " + cl.DocNumber,
ClientBad: defField == "investor",
OurCode: ourCode,
OurName: m2mPartyName(ourCode),
OurINN: ourINN,
OurAccount: nsd.DeponentCode + " / счёт " + nsd.AccountID + " / раздел " + nsd.SectionID,
CpCode: plan.CounterpartyCode,
CpName: plan.CounterpartyName,
CpINN: plan.CounterpartyINN,
Secs: secs,
}
render(w, a.m2mpreview, data)
})
// сохранение кода/ИНН контрагента
mux.HandleFunc("/admin/m2m-testing/counterparty", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
m2mMu.Lock()
plan := m2mLoadPlan()
plan.CounterpartyName = strings.TrimSpace(r.FormValue("counterparty_name"))
plan.CounterpartyCode = strings.TrimSpace(r.FormValue("counterparty_code"))
plan.CounterpartyINN = strings.TrimSpace(r.FormValue("counterparty_inn"))
plan.OurINN = strings.TrimSpace(r.FormValue("our_inn"))
_ = m2mSavePlan(plan)
m2mMu.Unlock()
http.Redirect(w, r, "/admin/m2m-testing", http.StatusSeeOther)
})
// установка статуса кейса
mux.HandleFunc("/admin/m2m-testing/status", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
status := strings.TrimSpace(r.FormValue("status"))
note := strings.TrimSpace(r.FormValue("note"))
m2mMu.Lock()
plan := m2mLoadPlan()
st := plan.States[id]
switch status {
case "reset":
st.Status = ""
case "":
// статус не меняем (например, сохранение только заметки)
default:
st.Status = status
}
if _, ok := r.Form["note"]; ok {
st.Note = note
}
st.Updated = time.Now()
plan.States[id] = st
_ = m2mSavePlan(plan)
m2mMu.Unlock()
// AJAX → 204, обычная форма → редирект
if r.Header.Get("X-Requested-With") == "fetch" {
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, "/admin/m2m-testing", http.StatusSeeOther)
})
// инициировать отправку исходящего M2M по кейсу (мы — Списывающий)
// Единая выгрузка отчётов по пройденным кейсам (для отправки контрагенту).
mux.HandleFunc("/admin/m2m-testing/depo-replay", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
report := depoReplayHistory(ctx, h.rc, h.svc, r.FormValue("cp"))
http.Redirect(w, r, "/admin/m2m-testing?flash="+urlQ("Выгрузка истории в DEPO: "+report), http.StatusSeeOther)
})
// План тестирования для команды контрагента (самостоятельный HTML).
mux.HandleFunc("/admin/m2m-testing/plan.html", func(w http.ResponseWriter, r *http.Request) {
writeM2MPlanHTML(w, r, h)
})
mux.HandleFunc("/admin/m2m-testing/report-bundle.zip", func(w http.ResponseWriter, r *http.Request) {
writeM2MReportBundle(w, r, h)
})
// Эталон ответов для программиста Fancy (входящие от контрагента + наши решения).
mux.HandleFunc("/admin/m2m-testing/fancy-bundle.zip", func(w http.ResponseWriter, r *http.Request) {
writeFancyBundle(w, r, h)
})
// Наглядное описание процесса M2M (для программиста) — с файлами каждого шага.
mux.HandleFunc("/admin/m2m-testing/process", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(fancyProcessHTML(buildProcessExample(r, h)))
})
// Справка по кодам ошибок/отказов M2M (простым языком).
mux.HandleFunc("/admin/m2m-testing/spravka", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(m2mSpravkaHTML(true))
})
mux.HandleFunc("/admin/m2m-testing/initiate", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
var cs *m2mCase
for i := range m2mCases {
if m2mCases[i].ID == id {
cs = &m2mCases[i]
break
}
}
// ВАЖНО: один явный редирект на ПЛАН (форма постится с /preview, поэтому
// setupFlash по Referer вернул бы на /preview без id → петля редиректов).
back := func(msg string) {
http.Redirect(w, r, "/admin/m2m-testing?flash="+url.QueryEscape(msg), http.StatusSeeOther)
}
if cs == nil {
back("M2M-тест: кейс " + id + " не найден")
return
}
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
if strings.TrimSpace(plan.CounterpartyCode) == "" {
back("Сначала укажите код участника контрагента (кнопка «Контрагент»)")
return
}
guid, err := h.m2mInitiate(*cs, plan)
m2mMu.Lock()
plan = m2mLoadPlan()
st := plan.States[id]
var msg string
if err != nil {
st.Status = "failed"
st.Note = "Отправка не удалась: " + err.Error()
msg = "Кейс " + id + ": отправка не удалась: " + err.Error()
} else {
st.Status = "in_progress"
st.GUID = guid
st.Note = "Отправлено контрагенту " + plan.CounterpartyCode + " (GUID " + guid + "). Ждём ответ."
msg = "Кейс " + id + ": исходящий M2M отправлен (GUID " + guid + "). Ответ придёт во «Входящие»."
}
st.Updated = time.Now()
plan.States[id] = st
_ = m2mSavePlan(plan)
m2mMu.Unlock()
back(msg)
})
}
// m2mInitiate строит исходящий M2M по кейсу и отправляет через НРД контрагенту.
func (h *setupHandlers) m2mInitiate(c m2mCase, plan m2mPlan) (string, error) {
if len(c.Secs) == 0 {
return "", fmt.Errorf("в кейсе %s нет бумаг для отправки (это кейс сценария 2 — формируется вручную)", c.ID)
}
nsd := h.rc.Snapshot().NSD
depCode, accID, sectID := nsd.DeponentCode, nsd.AccountID, nsd.SectionID
cl := m2mTestClients[c.Client]
secs := make([]ClaimSec, 0, len(c.Secs))
for _, s := range c.Secs {
qty := Quantity{Whole: uint64(atoiSafe(s.Qty))}
if s.Frac {
qty = Quantity{Fractional: s.Qty}
}
secs = append(secs, ClaimSec{
SecurityCode: s.Code,
SecurityDetails: SecurityDetails{ISIN: s.ISIN},
Quantity: qty,
SettlementAccounts: []SettlementAccount{{
SettlementRequisitesINN: m2mSettleINN(c.SettleCred),
SettlementLocation: SettlementLocation{
DeponentCode: depCode, AccountID: accID, SectionID: sectID,
},
}},
})
}
ourINN := plan.OurINN
if ourINN == "" {
ourINN = "7703807489" // дефолт — уточнить реквизитами нашего депозитария
}
recvINN := plan.CounterpartyINN
if recvINN == "" {
recvINN = "7722061076"
}
req := CreateClaimRequest{
Investor: Investor{
ID: cl.SeedID, // согласованная личность из сид-стора (ФИО+документ+счёт)
LastName: cl.Last, FirstName: cl.First, MiddleName: cl.Middle,
Document: Document{DocumentType: "21", Series: cl.DocSeries, Number: cl.DocNumber},
},
TransferringDepositoryINN: ourINN,
ReceivingDepositoryINN: recvINN,
CostInfo: CostInfo{Yes: &CostInfoYes{Code: depCode}},
Securities: secs,
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
SignatureFormat: "XMLDSig-GOST",
ReceiverCodeOverride: plan.CounterpartyCode,
InvestorDocumentOverride: &Document{DocumentType: "21", Series: cl.DocSeries, Number: cl.DocNumber},
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
created, err := h.svc.CreateClaim(ctx, req)
if err != nil {
return "", err
}
return created.ID, nil
}
// m2mCodeDesc — расшифровка кодов отказа/информирования для подсказок.
var m2mCodeDesc = map[string]string{
"M2M01": "Ценная бумага не допускается к переводам M2M (напр. не-российский ISIN). Сервис/зачисляющий отклоняет запрос.",
"M2M02": "Не может быть определён счёт для зачисления ЦБ (клиент не указал счёт по умолчанию, а он требуется). Зачисляющий отклоняет.",
"M2M03": "Счёт депо зачисления не торговый. Зачисляющий отклоняет перевод.",
"M2M04": "Инвестор не найден у зачисляющего депозитария. Отклонение.",
"M2M05": "Зачисляющий депозитарий не оказывает услуги по учёту прав на эту бумагу. Отклонение.",
"M2M06": "Приостановление операций по счёту депо зачисления. Отклонение.",
"M2M08": "Операция приведёт к нарушению законодательства РФ (напр. бумага для квалинвесторов, а инвестор не квалифицирован). Отклонение.",
}
// m2mCaseDesc строит человекочитаемое описание кейса для подсказки при наведении.
func m2mCaseDesc(c m2mCase) string {
var b strings.Builder
fmt.Fprintf(&b, "Списывает %s, зачисляет %s. Клиент %s.", c.Debit, c.Credit, c.Client)
if len(c.Secs) > 0 {
var ss []string
for _, s := range c.Secs {
ss = append(ss, s.Code+" ×"+s.Qty)
}
fmt.Fprintf(&b, " Бумаги: %s.", strings.Join(ss, ", "))
}
fmt.Fprintf(&b, " Расчёты: %s→%s.", c.SettleDeb, c.SettleCred)
switch {
case c.Expect == "":
b.WriteString(" Ожидается: УСПЕШНЫЙ перевод — бумаги списываются у списывающего и зачисляются зачисляющему, статус подтверждён.")
case m2mCodeDesc[c.Expect] != "":
fmt.Fprintf(&b, " Ожидается ОТКАЗ (%s): %s", c.Expect, m2mCodeDesc[c.Expect])
default:
fmt.Fprintf(&b, " Ожидается код сервиса МОСТ %s: %s", c.Expect, c.Note)
}
switch m2mOurRole(c) {
case "Зачисляющий (мы)":
if c.Expect == "" {
b.WriteString(" Наше действие: принять входящий перевод во «Входящих» (подтвердить зачисление).")
} else {
fmt.Fprintf(&b, " Наше действие: во «Входящих» ОТКЛОНИТЬ с кодом %s.", c.Expect)
}
case "Списывающий (мы)":
b.WriteString(" Наше действие: инициировать отправку и ждать решение контрагента.")
}
return b.String()
}
// ---- справочник участников: код/ИНН → наименование ----
var (
hbMu sync.Mutex
hbByCode map[string]string
hbByINN map[string]string
hbCodeINN map[string]string
hbEntries []HandbookEntry
hbLoadedAt time.Time
)
func handbookLoad() {
hbMu.Lock()
defer hbMu.Unlock()
if hbByCode != nil && time.Since(hbLoadedAt) < 5*time.Minute {
return
}
byCode, byINN, codeINN := map[string]string{}, map[string]string{}, map[string]string{}
var entries []HandbookEntry
if b, err := os.ReadFile(handbookFile); err == nil {
var doc struct {
Participants []HandbookEntry `json:"participants"`
Places []HandbookEntry `json:"settlement_places"`
}
if json.Unmarshal(b, &doc) == nil {
for _, p := range append(doc.Participants, doc.Places...) {
if p.Code != "" && p.Name != "" {
byCode[p.Code] = p.Name
}
if p.INN != "" && p.Name != "" {
byINN[p.INN] = p.Name
}
if p.Code != "" && p.INN != "" {
codeINN[p.Code] = p.INN
}
if p.Code != "" || p.INN != "" {
entries = append(entries, p)
}
}
}
}
hbByCode, hbByINN, hbCodeINN, hbEntries, hbLoadedAt = byCode, byINN, codeINN, entries, time.Now()
}
func handbookMaps() (map[string]string, map[string]string) {
handbookLoad()
return hbByCode, hbByINN
}
// m2mINNByCode — ИНН депозитария по MC-коду участника (из справочника).
func m2mINNByCode(code string) string {
handbookLoad()
hbMu.Lock()
defer hbMu.Unlock()
return hbCodeINN[strings.TrimSpace(code)]
}
// m2mHandbookJSON — участники справочника для пред-заполнения (dropdown).
func m2mHandbookJSON() template.JS {
handbookLoad()
hbMu.Lock()
entries := hbEntries
hbMu.Unlock()
b, _ := json.Marshal(entries)
return template.JS(b)
}
// m2mPartyName возвращает наименование организации по MC-коду участника или ИНН
// из справочника M2M (#M2MTH). Пусто, если не найдено.
func m2mPartyName(codeOrINN string) string {
s := strings.TrimSpace(codeOrINN)
if s == "" {
return ""
}
byCode, byINN := handbookMaps()
if n := byCode[s]; n != "" {
return n
}
return byINN[s]
}
// m2mIncomingHint — подсказка для страницы «Входящие»: что сделать по кейсу.
type m2mIncomingHint struct {
ID string `json:"id"`
Title string `json:"title"`
Action string `json:"action"` // "confirm" | "reject"
Code string `json:"code,omitempty"`
Text string `json:"text"` // человекочитаемая инструкция
}
// m2mIncomingHintsJSON отдаёт JSON подсказок по кейсам, где мы Зачисляющий
// (получаем входящий и отвечаем). Для дропдауна-подсказки на «Входящих».
func m2mIncomingHintsJSON() template.JS {
var hints []m2mIncomingHint
for _, c := range m2mCases {
if m2mExcluded[c.ID] || c.Scenario == "1-СПБ" {
continue
}
if m2mOurRole(c) != "Зачисляющий (мы)" || len(c.Secs) == 0 {
continue
}
h := m2mIncomingHint{ID: c.ID}
if c.Expect == "" {
h.Title = "Кейс " + c.ID + " — успешное зачисление"
h.Action = "confirm"
h.Text = "ПРИНЯТЬ все бумаги (подтвердить зачисление). Ожидается успешный перевод."
} else {
h.Title = "Кейс " + c.ID + " — отказ " + c.Expect
h.Action = "reject"
h.Code = c.Expect
meaning := m2mCodeDesc[c.Expect]
h.Text = "ОТКЛОНИТЬ с кодом " + c.Expect + " — " + meaning
}
hints = append(hints, h)
}
b, _ := json.Marshal(hints)
return template.JS(b)
}
// m2mLiveOutcome по GUID возвращает исход НРД: outcome (confirmed/rejected/partial),
// коды отказа, terminal (решение получено), ok (заявка найдена в системе).
func (h *setupHandlers) m2mLiveOutcome(ctx context.Context, guid string) (outcome string, codes []string, terminal, ok bool) {
v, err := h.svc.GetClaim(ctx, guid)
if err != nil {
return "", nil, false, false
}
if v.M2MDecision == nil {
switch v.Status {
case "done", "confirmed":
return "confirmed", nil, true, true
case "rejected", "declined":
return "rejected", nil, true, true
case "partial":
return "partial", nil, true, true
}
return "", nil, false, true // решение ещё не пришло
}
conf, rej := 0, 0
for _, s := range v.M2MDecision.Securities {
if s.Outcome == "rejected" {
rej++
codes = append(codes, s.RejectCodes...)
} else {
conf++
}
}
switch {
case rej == 0:
outcome = "confirmed"
case conf == 0:
outcome = "rejected"
default:
outcome = "partial"
}
return outcome, uniqStrings(codes), true, true
}
// autoVerdict сравнивает ожидаемое (c.Expect) с фактическим исходом и кодами и
// возвращает "passed"/"failed".
func autoVerdict(expect, outcome string, codes []string) string {
if strings.TrimSpace(expect) == "" { // ожидали успех
if outcome == "confirmed" {
return "passed"
}
return "failed"
}
// ожидали отказ с конкретным кодом
if outcome == "rejected" || outcome == "partial" {
for _, c := range codes {
if strings.EqualFold(strings.TrimSpace(c), strings.TrimSpace(expect)) {
return "passed"
}
}
if len(codes) == 0 {
return "passed" // отказ есть, кодов не видно — считаем ожидаемым
}
return "failed" // код отказа не совпал с ожидаемым
}
return "failed" // ожидали отказ, а НРД подтвердил
}
func uniqStrings(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// m2mSettleINN — ИНН расчётного депозитария по названию площадки из тест-плана
// ("СПБ"/"НРД"). Приводит к коду площадки и делегирует settlementPlaceINN.
func m2mSettleINN(place string) string {
if strings.EqualFold(strings.TrimSpace(place), "СПБ") {
return settlementPlaceINN("spb")
}
return settlementPlaceINN("nrd")
}