da12a9ec1c
- 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>
893 lines
37 KiB
Go
893 lines
37 KiB
Go
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")
|
||
}
|