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>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+652
View File
@@ -0,0 +1,652 @@
package lkgateway
// fancy_bundle.go — полный «эталон» тестового взаимодействия с контрагентом (Банк
// ЗЕНИТ) для программиста Fancy: ОБЕ стороны обмена —
// • входящие (контрагент → мы): что пришло + какое решение мы дали руками;
// • исходящие (мы → контрагент): что мы отправили + что нам ответил контрагент.
// По каждому переводу — дословные XML (request/response/decision) и пояснение.
// Цель: настроить Fancy так, чтобы она формировала те же запросы и те же ответы.
import (
"archive/zip"
"bytes"
"fmt"
"html"
"net/http"
"sort"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
// fancyRule — правило ответа Fancy по коду решения (что Fancy должна проверить).
var fancyRule = map[string]string{
"": "Все проверки пройдены: бумага обслуживается зачисляющим, счёт зачисления определён и является торговым, инвестор найден, ограничений нет → ПРИНЯТЬ (Confirmation).",
"M2M02": "Счёт депо для зачисления не может быть определён (у инвестора нет счёта по умолчанию, а он требуется) → отказ M2M02.",
"M2M03": "Счёт депо зачисления не является торговым → отказ M2M03.",
"M2M04": "Инвестор не найден у зачисляющего депозитария → отказ M2M04.",
"M2M05": "Зачисляющий не обслуживает данную бумагу (например, Газпром RU0007661625) → отказ M2M05.",
"M2M06": "По счёту депо зачисления приостановлены операции → отказ M2M06.",
"M2M08": "Операция нарушит закон РФ (например, бумага для квалинвесторов, а инвестор не квалифицирован) → отказ M2M08.",
}
type fancyItem struct {
it *IncomingTransfer
code string
decision string
folder string
}
type fancyOutItem struct {
v ClaimView
req []byte
resp []byte
dec []byte
outcome string
code string
folder string
}
// isTestClientDoc — документ принадлежит тест-клиенту плана (К1/К2 из m2mTestClients),
// а не робот-заявке (2001/111111, 3333/100000 и т.п.).
func isTestClientDoc(series, number string) bool {
for _, cl := range m2mTestClients {
if cl.DocSeries == series && cl.DocNumber == number {
return true
}
}
return false
}
// writeFancyBundle собирает ZIP-эталон обмена с контрагентом (обе стороны).
func writeFancyBundle(w http.ResponseWriter, r *http.Request, h *setupHandlers) {
ctx := r.Context()
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
cp := strings.ToUpper(strings.TrimSpace(plan.CounterpartyCode))
// --- входящие (контрагент → мы) ---
var in []fancyItem
if globalIncomingStore != nil {
for _, it := range globalIncomingStore.List() {
if it.Status != "answered" {
continue
}
if cp != "" && strings.ToUpper(it.SenderCode) != cp {
continue
}
code, dec := fancyDecision(it)
in = append(in, fancyItem{it: it, code: code, decision: dec})
}
}
sort.Slice(in, func(i, j int) bool { return in[i].it.ReceivedAt.Before(in[j].it.ReceivedAt) })
for i := range in {
tag := "priem"
if in[i].code != "" {
tag = "otkaz-" + in[i].code
}
in[i].folder = fmt.Sprintf("vhodyashie--kontragent-nam/%02d-%s/", i+1, tag)
}
// --- исходящие (мы → контрагент), только реальные тест-клиенты ---
var out []fancyOutItem
if page, err := h.svc.ListClaims(ctx, m2mcore.Filter{Limit: 5000}); err == nil {
for _, v := range page.Items {
if !isTestClientDoc(v.Investor.Document.Series, v.Investor.Document.Number) {
continue
}
oi := fancyOutItem{v: v, outcome: nsdOutcomeSummary(v), code: claimRejectCode(v)}
oi.req, _ = h.svc.ClaimRawXML(ctx, v.ID, "request")
oi.resp, _ = h.svc.ClaimRawXML(ctx, v.ID, "response")
oi.dec, _ = h.svc.ClaimRawXML(ctx, v.ID, "decision")
out = append(out, oi)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].v.CreatedAt.Before(out[j].v.CreatedAt) })
for i := range out {
tag := "prinyato"
if out[i].v.Status == "rejected" {
tag = "otkaz"
if out[i].code != "" {
tag += "-" + out[i].code
}
}
out[i].folder = fmt.Sprintf("ishodyashie--my-kontragentu/%02d-%s/", i+1, tag)
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if f, e := zw.Create("00-kak-rabotaet-process.html"); e == nil {
_, _ = f.Write(fancyProcessHTML(processExample{})) // офлайн: файлы шагов — в папках ниже
}
if f, e := zw.Create("opisanie-dlya-fancy.html"); e == nil {
_, _ = f.Write(fancyBundleDocHTML(plan, in, out))
}
// входящие
for _, fi := range in {
if fi.it.RawXML != "" {
writeZipFile(zw, fi.folder+"request-ot-kontragenta.xml", faithfulXMLBytes(fi.it.RawXML))
}
if fi.it.DecisionXML != "" {
writeZipFile(zw, fi.folder+"nash-otvet-decision.xml", faithfulXMLBytes(fi.it.DecisionXML))
}
writeZipFile(zw, fi.folder+"opisanie.txt", []byte(fancyItemNote(fi)))
}
// исходящие
for _, oi := range out {
if len(oi.req) > 0 {
writeZipFile(zw, oi.folder+"nash-request.xml", oi.req)
}
if len(oi.resp) > 0 {
writeZipFile(zw, oi.folder+"otvet-servisa-response.xml", oi.resp)
}
if len(oi.dec) > 0 {
writeZipFile(zw, oi.folder+"otvet-kontragenta-decision.xml", oi.dec)
}
writeZipFile(zw, oi.folder+"opisanie.txt", []byte(fancyOutNote(oi)))
}
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fname := "fancy-etalon-obmena-" + time.Now().Format("2006-01-02") + ".zip"
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`)
_, _ = w.Write(buf.Bytes())
}
func writeZipFile(zw *zip.Writer, name string, data []byte) {
if f, e := zw.Create(name); e == nil {
_, _ = f.Write(data)
}
}
// claimRejectCode — первый код отказа по заявке (из решения контрагента или ответа сервиса).
func claimRejectCode(v ClaimView) string {
if v.M2MDecision != nil {
for _, s := range v.M2MDecision.Securities {
if len(s.RejectCodes) > 0 {
return s.RejectCodes[0]
}
}
}
if v.M2MResponse != nil {
for _, e := range v.M2MResponse.Responses {
if e.Code != "" {
return e.Code
}
}
}
return ""
}
// fancyDecision — код и человекочитаемое решение по входящему (по бумагам).
func fancyDecision(it *IncomingTransfer) (code, text string) {
conf, rej := 0, 0
var codes []string
for _, s := range it.Securities {
switch s.Decision {
case "confirmed":
conf++
case "rejected":
rej++
if s.RejectCode != "" && !contains(codes, s.RejectCode) {
codes = append(codes, s.RejectCode)
}
}
}
switch {
case rej == 0 && conf > 0:
return "", "Принято"
case conf == 0 && rej > 0:
return codes[0], "Отказано (" + strings.Join(codes, ", ") + ")"
case conf > 0 && rej > 0:
return codes[0], fmt.Sprintf("Частично: принято %d, отказано %d (%s)", conf, rej, strings.Join(codes, ", "))
}
return "", "Отвечено"
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
func fancyItemNote(fi fancyItem) string {
it := fi.it
var b strings.Builder
b.WriteString("=== ВХОДЯЩИЙ (контрагент → мы). Fancy должна дать такое же решение ===\n\n")
fmt.Fprintf(&b, "От контрагента: %s\n", it.SenderCode)
fmt.Fprintf(&b, "GUID: %s\n", it.GUID)
fmt.Fprintf(&b, "Получен: %s МСК\n", mskFmt(it.ReceivedAt))
fmt.Fprintf(&b, "Инвестор: %s %s %s, паспорт %s № %s\n", it.InvestorLast, it.InvestorFirst, it.InvestorMiddle, it.DocSeries, it.DocNumber)
b.WriteString("Бумаги:\n")
for _, s := range it.Securities {
key := s.ISIN
if key == "" {
key = s.SecurityCode
}
fmt.Fprintf(&b, " - %s × %s → %s %s\n", key, s.Quantity, ruDecision(s.Decision), s.RejectCode)
}
fmt.Fprintf(&b, "\nНАШЕ РЕШЕНИЕ: %s\n", fi.decision)
fmt.Fprintf(&b, "ПРАВИЛО ДЛЯ FANCY: %s\n", fancyRule[fi.code])
b.WriteString("\nФайлы: request-ot-kontragenta.xml — что прислал контрагент; nash-otvet-decision.xml — что мы ответили (эталон).\n")
return b.String()
}
func fancyOutNote(oi fancyOutItem) string {
v := oi.v
var b strings.Builder
b.WriteString("=== ИСХОДЯЩИЙ (мы → контрагент). Fancy должна сформировать такой же запрос ===\n\n")
fmt.Fprintf(&b, "GUID: %s\n", v.M2MGUID)
fmt.Fprintf(&b, "Создан: %s МСК\n", mskFmt(v.CreatedAt))
fmt.Fprintf(&b, "Инвестор: %s, паспорт %s № %s\n", fio(v.Investor), v.Investor.Document.Series, v.Investor.Document.Number)
b.WriteString("Бумаги:\n")
for _, s := range v.Securities {
q := s.Quantity.Fractional
if q == "" {
q = fmt.Sprintf("%d", s.Quantity.Whole)
}
fmt.Fprintf(&b, " - %s × %s\n", s.SecurityCode, q)
}
fmt.Fprintf(&b, "\nОТВЕТ КОНТРАГЕНТА/СЕРВИСА: %s (статус заявки: %s)\n", oi.outcome, russianState(v.Status))
if oi.code != "" {
fmt.Fprintf(&b, "Код: %s — %s\n", oi.code, m2mCodeText(oi.code))
}
b.WriteString("\nФайлы: nash-request.xml — что мы отправили; otvet-servisa-response.xml / otvet-kontragenta-decision.xml — что нам ответили.\n")
return b.String()
}
func mskFmt(t time.Time) string {
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
}
func ruDecision(d string) string {
switch d {
case "confirmed":
return "ПРИНЯТО"
case "rejected":
return "ОТКАЗ"
}
return "—"
}
// fancyBundleDocHTML — сопроводительное описание для программиста Fancy (обе стороны).
func fancyBundleDocHTML(plan m2mPlan, in []fancyItem, out []fancyOutItem) []byte {
esc := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Эталон обмена для Fancy</title><style>`)
b.WriteString(`body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1a1a1a;max-width:1080px;margin:24px auto;padding:0 20px;line-height:1.5}`)
b.WriteString(`h1{font-size:20px}h2{font-size:16px;margin:24px 0 8px;color:#1f4e79}table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0}`)
b.WriteString(`td,th{border:1px solid #ccc;padding:7px 9px;text-align:left;vertical-align:top}th{background:#f0f0f0}`)
b.WriteString(`.mono{font-family:monospace;font-size:11.5px}.ok{color:#1a7f37;font-weight:700}.no{color:#b3261e;font-weight:700}.muted{color:#666}`)
b.WriteString(`</style></head><body>`)
b.WriteString(`<div style="display:flex;align-items:center;gap:14px;border-bottom:2px solid #ff0508;padding-bottom:10px;margin-bottom:14px">` + moexMostLogoSVG + orgLogoIMG() + `</div>`)
b.WriteString(`<h1>Эталон тестового обмена M2M для интеграции с Fancy</h1>`)
cpName := plan.CounterpartyName
if cpName == "" {
cpName = plan.CounterpartyCode
}
b.WriteString(`<p>Полная запись тестового обмена с контрагентом <b>` + esc(cpName) + `</b> (код <span class="mono">` + esc(plan.CounterpartyCode) + `</span>), обе стороны. Задача: настроить Fancy так, чтобы по такому же входящему она давала такое же решение, а исходящие формировала так же, как здесь.</p>`)
b.WriteString(fmt.Sprintf(`<p class="muted" style="font-size:13px">В архиве: <b>%d</b> входящих (папка <span class="mono">vhodyashie--kontragent-nam/</span>) и <b>%d</b> исходящих (папка <span class="mono">ishodyashie--my-kontragentu/</span>), только по реальным тест-клиентам (Бубликов, Баранкин) — робот-заявки исключены. В каждой папке дословные XML и <span class="mono">opisanie.txt</span>.</p>`, len(in), len(out)))
// Входящие
b.WriteString(`<h2>1. Входящие (контрагент → мы) — какое решение должна дать Fancy</h2><table>`)
b.WriteString(`<tr><th>№ / папка</th><th>Время</th><th>Инвестор</th><th>Бумага × кол-во</th><th>Наше решение</th><th>Правило для Fancy</th></tr>`)
for i, fi := range in {
it := fi.it
var secs []string
for _, s := range it.Securities {
key := s.ISIN
if key == "" {
key = s.SecurityCode
}
secs = append(secs, esc(key)+" × "+esc(s.Quantity))
}
dcls := "ok"
if fi.code != "" {
dcls = "no"
}
fioIn := strings.TrimSpace(it.InvestorLast + " " + it.InvestorFirst + " " + it.InvestorMiddle)
b.WriteString(`<tr>`)
b.WriteString(fmt.Sprintf(`<td><b>%02d</b><br><span class="mono">%s</span></td>`, i+1, esc(shortFolder(fi.folder))))
b.WriteString(`<td>` + esc(it.ReceivedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04")) + `</td>`)
b.WriteString(`<td>` + esc(fioIn) + `<br><span class="muted" style="font-size:11px">пасп. ` + esc(it.DocSeries) + ` № ` + esc(it.DocNumber) + `</span></td>`)
b.WriteString(`<td class="mono">` + strings.Join(secs, "<br>") + `</td>`)
b.WriteString(`<td class="` + dcls + `">` + esc(fi.decision) + `</td>`)
b.WriteString(`<td style="font-size:12px">` + esc(fancyRule[fi.code]) + `</td>`)
b.WriteString(`</tr>`)
}
b.WriteString(`</table>`)
// Исходящие
b.WriteString(`<h2>2. Исходящие (мы → контрагент) — что мы отправляли и что нам ответили</h2><table>`)
b.WriteString(`<tr><th>№ / папка</th><th>Время</th><th>Инвестор</th><th>Бумага</th><th>Ответ контрагента</th></tr>`)
for i, oi := range out {
v := oi.v
var secs []string
for _, s := range v.Securities {
secs = append(secs, esc(s.SecurityCode))
}
ocls := "ok"
if v.Status == "rejected" {
ocls = "no"
}
res := esc(oi.outcome)
if oi.code != "" {
res += `<br><span class="muted" style="font-size:11px">` + esc(m2mCodeText(oi.code)) + `</span>`
}
b.WriteString(`<tr>`)
b.WriteString(fmt.Sprintf(`<td><b>%02d</b><br><span class="mono">%s</span></td>`, i+1, esc(shortFolder(oi.folder))))
b.WriteString(`<td>` + esc(v.CreatedAt.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04")) + `</td>`)
b.WriteString(`<td>` + esc(fio(v.Investor)) + `<br><span class="muted" style="font-size:11px">пасп. ` + esc(v.Investor.Document.Series) + ` № ` + esc(v.Investor.Document.Number) + `</span></td>`)
b.WriteString(`<td class="mono">` + strings.Join(secs, "<br>") + `</td>`)
b.WriteString(`<td class="` + ocls + `">` + res + `</td>`)
b.WriteString(`</tr>`)
}
b.WriteString(`</table>`)
b.WriteString(`<h2>Коды отказа — что проверяет Fancy</h2><table><tr><th>Код</th><th>Значение</th><th>Правило</th></tr>`)
for _, c := range []string{"M2M01", "M2M02", "M2M03", "M2M04", "M2M05", "M2M06", "M2M08"} {
rule := fancyRule[c]
if rule == "" {
rule = m2mCodeText(c)
}
b.WriteString(`<tr><td class="mono">` + c + `</td><td>` + esc(m2mCodeText(c)) + `</td><td style="font-size:12px">` + esc(rule) + `</td></tr>`)
}
b.WriteString(`</table>`)
b.WriteString(`<p class="muted" style="font-size:11px;margin-top:16px">Все XML — дословные (windows-1251), как прошли через НРД. Отобраны только реальные тест-клиенты; служебные робот-прогоны в пакет не включены.</p>`)
b.WriteString(`</body></html>`)
return []byte(b.String())
}
// fancyProcessHTML — описание всего процесса M2M «как это работает у нас»:
// что даёт НРД, как мы обрабатываем, как отправляем обратно через шлюз ИШ.
// Отдельный акцент на депозитарных поручениях 16 (списание) и 16/1 (зачисление).
func fancyProcessHTML(ex processExample) []byte {
var b strings.Builder
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Как работает процесс M2M (16 / 16.1)</title><style>`)
b.WriteString(`body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1a1a1a;max-width:1000px;margin:24px auto;padding:0 20px;line-height:1.55}`)
b.WriteString(`h1{font-size:21px}h2{font-size:16px;margin:26px 0 8px;color:#1f4e79;border-bottom:1px solid #e3e3e8;padding-bottom:4px}h3{font-size:14px;margin:16px 0 4px}`)
b.WriteString(`table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0}td,th{border:1px solid #ccc;padding:7px 9px;text-align:left;vertical-align:top}th{background:#f0f0f0}`)
b.WriteString(`code,.mono{font-family:monospace;font-size:12.5px;background:#f5f5f7;padding:1px 4px;border-radius:4px}`)
b.WriteString(`.step{display:flex;gap:12px;margin:8px 0;padding:11px 14px;background:#f7f9fc;border-left:3px solid #1f4e79;border-radius:0 8px 8px 0}`)
b.WriteString(`.step .n{flex:none;width:26px;height:26px;border-radius:50%;background:#1f4e79;color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:13px}`)
b.WriteString(`.who{display:inline-block;font-size:11px;font-weight:700;padding:1px 7px;border-radius:10px;margin-right:6px}`)
b.WriteString(`.w-nsd{background:#fde7e7;color:#b3261e}.w-us{background:#e6f0fb;color:#1f4e79}.w-ish{background:#eee6fb;color:#5b2ea6}.w-cp{background:#e6f4ea;color:#1a7f37}`)
b.WriteString(`.muted{color:#666}.note{background:#fff8e6;border:1px solid #f0d98a;border-radius:8px;padding:10px 12px;font-size:13px;margin:10px 0}`)
b.WriteString(`.flow{display:flex;flex-wrap:wrap;align-items:center;gap:8px 6px;margin:10px 0 18px}`)
b.WriteString(`.fchip{padding:9px 13px;border-radius:10px;font-size:13px;font-weight:600;line-height:1.25;border:1.5px solid}`)
b.WriteString(`.fchip small{display:block;font-weight:500;font-size:11px;color:#555}`)
b.WriteString(`.c-us{background:#e6f0fb;border-color:#1f4e79}.c-nsd{background:#fde7e7;border-color:#e0a0a0}.c-cp{background:#eefaf1;border-color:#9ad3ab}.c-done{background:#eafbef;border-color:#1a7f37;color:#0f7a37}`)
b.WriteString(`.arr{color:#999;font-size:18px;font-weight:700}`)
b.WriteString(`.dl{display:inline-block;font-size:12px;font-weight:700;padding:4px 10px;border-radius:8px;background:#e6f0fb;border:1px solid #1f4e79;color:#1f4e79;text-decoration:none}`)
b.WriteString(`.dl:hover{background:#d3e4f7}.dl-off{font-size:11.5px;color:#999;font-style:italic}`)
b.WriteString(`.did{margin-top:6px;font-size:12.5px}.sys{margin-top:4px;font-size:12px;color:#5b2ea6}`)
b.WriteString(`</style></head><body>`)
b.WriteString(`<div style="display:flex;align-items:center;gap:14px;border-bottom:2px solid #ff0508;padding-bottom:10px;margin-bottom:14px">` + moexMostLogoSVG + orgLogoIMG() + `</div>`)
b.WriteString(`<h1>Как работает процесс перевода ценных бумаг (M2M)</h1>`)
b.WriteString(`<p class="muted">Наглядная схема процесса для программиста: как мы формируем и отправляем, что присылает НРД (файлы 16 / 16.1), как мы их обрабатываем и кладём в шлюз, и как итог — бумаги списываются и зачисляются. Ниже — обе стороны и пошаговые детали.</p>`)
chip := func(cls, title, sub string) string {
s := ""
if sub != "" {
s = `<small>` + sub + `</small>`
}
return `<div class="fchip ` + cls + `">` + title + s + `</div>`
}
arr := `<span class="arr">→</span>`
b.WriteString(`<h2>Схема: исходящий перевод (мы отправляем, бумаги СПИСЫВАЮТСЯ)</h2>`)
b.WriteString(`<div class="flow">` +
chip("c-us", "1. Мы сформировали заявку", "M2MTransferRequest") + arr +
chip("c-nsd", "2. Ушло в НРД", "через шлюз ИШ") + arr +
chip("c-cp", "3. Контрагент ответил", "согласие") + arr +
chip("c-nsd", "4. НРД прислал файл 16", "черновик поручения (списание)") + arr +
chip("c-us", "5. Мы обработали → шлюз", "подали поручение ORDXM") + arr +
chip("c-done", "6. ✓ Бумаги списаны", "отчёт MS010") +
`</div>`)
b.WriteString(`<h2>Схема: входящий перевод (нам присылают, бумаги ЗАЧИСЛЯЮТСЯ)</h2>`)
b.WriteString(`<div class="flow">` +
chip("c-cp", "1. Контрагент отправил", "M2MTransferRequest") + arr +
chip("c-nsd", "2. НРД прислал нам", "запрос в INBOX") + arr +
chip("c-us", "3. Мы ответили", "решение: принять/отказать") + arr +
chip("c-nsd", "4. НРД прислал файл 16.1", "черновик поручения (зачисление)") + arr +
chip("c-us", "5. Мы обработали → шлюз", "подали поручение ORDXM") + arr +
chip("c-done", "6. ✓ Бумаги зачислены", "отчёт MS010") +
`</div>`)
// Компоненты
b.WriteString(`<h2>Участники и компоненты</h2><table>`)
b.WriteString(`<tr><th>Кто</th><th>Роль</th></tr>`)
b.WriteString(`<tr><td><span class="who w-nsd">НРД / МОСТ</span></td><td>Сервис быстрых переводов ценных бумаг MOEX МОСТ. Проводит сделку, формирует черновики поручений, выдаёт отчёты.</td></tr>`)
b.WriteString(`<tr><td><span class="who w-ish">ИШ (шлюз)</span></td><td>Интеграционный шлюз НРД (igate). Приём/отправка пакетов, ГОСТ-подпись. Папки обмена: <code>INBOX</code> (пришло от НРД), <code>OUTBOX/SENT/ERRORS</code>. REST API: <code>POST /api/package/{канал}/file</code>, <code>GET /api/package?channel=&amp;type=</code>, <code>GET /api/package/{id}</code>.</td></tr>`)
b.WriteString(`<tr><td><span class="who w-us">bj (мы)</span></td><td>Наша система. Читает INBOX, принимает решения, упаковывает и шлёт документы через ИШ, отслеживает ответы.</td></tr>`)
b.WriteString(`<tr><td><span class="who w-cp">Контрагент</span></td><td>Депозитарий на другой стороне перевода (в тестах — Банк ЗЕНИТ).</td></tr>`)
b.WriteString(`<tr><td>Fancy</td><td>Учётная (депозитарная) система. Должна принимать решения по входящим и формировать исходящие так же, как мы делали руками.</td></tr>`)
b.WriteString(`</table>`)
// Типы ЭД
b.WriteString(`<h2>Типы электронных документов (ЭД)</h2><table>`)
b.WriteString(`<tr><th>Документ</th><th>Тип / пакет</th><th>Кто шлёт → кому</th><th>Смысл</th></tr>`)
b.WriteString(`<tr><td>M2MTransferRequest</td><td class="mono">#M2MTR</td><td>инициатор → НРД → контрагент</td><td>Запрос на перевод ценных бумаг</td></tr>`)
b.WriteString(`<tr><td>M2MTransferResponse</td><td class="mono">#M2MER</td><td>НРД → инициатор</td><td>Ответ сервиса МОСТ (коды M2Mxx, ошибки/информирование)</td></tr>`)
b.WriteString(`<tr><td>M2MTransferDecision</td><td class="mono">#M2MTD</td><td>зачисляющий → НРД → списывающий</td><td>Решение по бумагам: принять / отказать (с кодом)</td></tr>`)
b.WriteString(`<tr><td>Черновик поручения (MF010)</td><td class="mono">#M2MDO</td><td>НРД → обе стороны</td><td>Черновик формы <b>16</b> (списание) или <b>16/1</b> (зачисление). Отличаются полем <code>&lt;order_t_id&gt;</code></td></tr>`)
b.WriteString(`<tr><td>Поручение депо</td><td class="mono">ORDXM</td><td>депозитарий → НРД</td><td>Поданный черновик 16 / 16/1 как классическое поручение (<code>config.xml &lt;package&gt;K&lt;/package&gt;</code>)</td></tr>`)
b.WriteString(`<tr><td>Уведомление о принятии</td><td class="mono">REORD</td><td>НРД → депозитарий</td><td>НРД принял поручение депо</td></tr>`)
b.WriteString(`<tr><td>Отчёт по поручению депо</td><td class="mono">XMLRP = <b>MS010</b></td><td>НРД → депозитарий</td><td>Операция проведена (внутри zip: <code>repxml.xml</code> с <code>MS010</code>/<code>sese.025</code> + dbf)</td></tr>`)
b.WriteString(`</table>`)
// Входящий процесс (16/1)
b.WriteString(`<h2>Процесс А. Входящий перевод — мы ЗАЧИСЛЯЮЩИЙ (поручение 16/1)</h2>`)
b.WriteString(`<p class="muted">Контрагент списывает бумаги и переводит их нам. Мы решаем — принять или отказать, затем подаём встречное поручение 16/1 (зачисление).</p>`)
render := func(items []pstep) {
for i, it := range items {
var dl string
if it.href != "" {
dl = `<div style="margin-top:6px"><a class="dl" href="` + it.href + `" download>⬇ ` + it.dlabel + `</a></div>`
}
did := ""
if it.did != "" {
did = `<div class="did"><b>Что делаем:</b> ` + it.did + `</div>`
}
sys := ""
if it.system != "" {
sys = `<div class="sys"><b>Куда вносится:</b> ` + it.system + `</div>`
}
b.WriteString(`<div class="step"><div class="n">` + fmt.Sprintf("%d", i+1) + `</div><div>` + it.who + it.text + dl + did + sys + `</div></div>`)
}
}
hIf := func(cond bool, href string) string {
if cond {
return href
}
return ""
}
inURL := func(seg string) string { return "/admin/incoming/" + ex.InID + "/" + seg }
clURL := func(seg string) string { return "/admin/claims/" + ex.OutClaimID + "/" + seg }
outURL := func(seg string) string { return "/admin/outgoing/" + ex.OutGUID + "/" + seg }
render([]pstep{
{who: `<span class="who w-nsd">НРД</span>`, text: `Кладёт нам в <code>INBOX</code> пакет <code>#M2MTR</code> — <b>M2MTransferRequest</b> (инвестор, документ, какие бумаги нам переводят).`,
href: hIf(ex.InID != "" && ex.InHasReq, inURL("request.xml")), dlabel: "Запрос контрагента (request.xml)",
did: `разбираем, показываем во «Входящих», сверяем данные по каждой бумаге.`, system: `реестр bj; <b>сверка данных — Fancy</b> (депозитарный учёт).`},
{who: `<span class="who w-us">bj</span> → <span class="who w-ish">ИШ</span>`, text: `Формируем <b>M2MTransferDecision</b> — по каждой бумаге принять/отказать (+ код отказа), шлём пакетом <code>#M2MTD</code> (GUID ответа = GUID запроса).`,
href: hIf(ex.InID != "" && ex.InHasDec, inURL("decision.xml")), dlabel: "Наше решение (decision.xml)",
did: `сверяем данные запроса с картотекой (см. раздел «Что сверяем» ниже) и решаем.`, system: `уходит в <b>НРД</b> через шлюз ИШ.`},
{who: `<span class="who w-nsd">НРД</span>`, text: `Если приняли — кладёт в <code>INBOX</code> <b>черновик поручения 16/1</b> (<code>..._MF010.xml</code>, <code>&lt;order_t_id&gt;16/1&lt;/order_t_id&gt;</code>, <code>&lt;based_on&gt;</code>=GUID, <code>&lt;contr_d_id&gt;</code>=№ поручения).`,
href: hIf(ex.InID != "" && ex.InHasDraft, inURL("draft.zip")), dlabel: "Черновик 16/1 от НРД (draft.zip)",
did: `связываем по <code>based_on</code>=GUID, готовим к подаче.`, system: `<b>Fancy</b> (депоучёт) — формирование/подача поручения.`},
{who: `<span class="who w-us">bj</span> → <span class="who w-ish">ИШ</span>`, text: `Подаём черновик 16/1 в НРД как <b>поручение депо ORDXM</b>: XML как есть (windows-1251) в zip с <code>&lt;package&gt;K&lt;/package&gt;</code>, <code>POST /api/package/{канал}/file</code>. ИШ подписывает ГОСТ.`,
href: hIf(ex.InID != "" && ex.InHasOrder, inURL("order.xml")), dlabel: "Наше поручение 16/1 (order.xml)",
did: `упаковали и отправили поручение депо в НРД.`, system: `<b>НРД</b> через шлюз ИШ.`},
{who: `<span class="who w-nsd">НРД</span>`, text: `Присылает квитанцию <code>XCONF</code> → принятие <code>REORD</code> → сматчив встречное поручение 16 контрагента, проводит зачисление и присылает отчёт <b>MS010</b> (<code>XMLRP</code>).`,
href: hIf(ex.InID != "" && ex.InHasMS010, inURL("ms010.zip")), dlabel: "Ответ НРД MS010 (ms010.zip)",
did: `находим по <code>contr_d_id</code>, статус «Проведено · MS010» — бумаги зачислены.`, system: `провести зачисление в <b>Fancy</b>; блокировки бумаг — <b>QUIK МНП</b>.`},
})
// Исходящий процесс (16)
b.WriteString(`<h2>Процесс Б. Исходящий перевод — мы СПИСЫВАЮЩИЙ (поручение 16)</h2>`)
b.WriteString(`<p class="muted">Мы списываем бумаги инвестора и переводим их контрагенту. Зеркально процессу А, но черновик приходит формы 16 (списание).</p>`)
outRespHref := ""
if ex.OutClaimID != "" {
if ex.OutHasDec {
outRespHref = clURL("decision.xml")
} else if ex.OutHasResp {
outRespHref = clURL("response.xml")
}
}
render([]pstep{
{who: `<span class="who w-us">bj</span> → <span class="who w-ish">ИШ</span>`, text: `Формируем <b>M2MTransferRequest</b> (инвестор, бумаги, счёт списания) и шлём пакетом <code>#M2MTR</code>.`,
href: hIf(ex.OutClaimID != "" && ex.OutHasReq, clURL("request.xml")), dlabel: "Наш запрос (request.xml)",
did: `собираем запрос по заявке клиента.`, system: `запрос из <b>Fancy</b> (заявка клиента) → <b>НРД</b> через ИШ.`},
{who: `<span class="who w-nsd">НРД</span> / <span class="who w-cp">Контрагент</span>`, text: `Приходит <code>#M2MER</code> (<b>M2MTransferResponse</b>, коды сервиса) и <b>M2MTransferDecision</b> контрагента — согласие/отказ.`,
href: outRespHref, dlabel: "Ответ (decision/response.xml)",
did: `разбираем ответ; при согласии ждём черновик 16.`, system: `результат — в <b>Fancy</b> (статус заявки).`},
{who: `<span class="who w-nsd">НРД</span>`, text: `После согласия кладёт в <code>INBOX</code> <b>черновик поручения 16</b> (<code>..._MF010.xml</code>, <code>&lt;order_t_id&gt;16&lt;/order_t_id&gt;</code> — списание).`,
href: hIf(ex.OutGUID != "" && ex.OutHasDraft, outURL("draft.zip")), dlabel: "Черновик 16 от НРД (draft.zip)",
did: `связываем по GUID, готовим к подаче.`, system: `<b>Fancy</b> (депоучёт) — подача поручения.`},
{who: `<span class="who w-us">bj</span> → <span class="who w-ish">ИШ</span>`, text: `Подаём черновик 16 в НРД как поручение депо <b>ORDXM</b> (тот же механизм: zip + <code>&lt;package&gt;K&lt;/package&gt;</code>, ИШ подписывает).`,
href: hIf(ex.OutGUID != "" && ex.OutHasOrder, outURL("order.xml")), dlabel: "Наше поручение 16 (order.xml)",
did: `упаковали и отправили поручение депо.`, system: `<b>НРД</b> через шлюз ИШ.`},
{who: `<span class="who w-nsd">НРД</span>`, text: `Матчит наш 16 со встречным 16/1 контрагента, проводит списание и присылает отчёт <b>MS010</b> — бумага ушла контрагенту.`,
href: hIf(ex.OutGUID != "" && ex.OutHasMS010, outURL("ms010.zip")), dlabel: "Ответ НРД MS010 (ms010.zip)",
did: `статус «Списание проведено · MS010».`, system: `провести списание в <b>Fancy</b>; блокировки — <b>QUIK МНП</b>.`},
})
// Что Fancy сверяет по данным
b.WriteString(`<h2>Что Fancy сверяет по данным (почему принимаем / почему отказываем)</h2>`)
b.WriteString(`<p class="muted">По входящему запросу Fancy сверяет данные из запроса со своей картотекой. Если всё совпало и ограничений нет — принимаем. Если что-то не совпало — отказываем с соответствующим кодом.</p>`)
b.WriteString(`<table><tr><th>Данные из запроса</th><th>С чем сверяем в Fancy</th><th>✓ Совпало → принимаем</th><th>✗ Не совпало → отказ</th></tr>`)
chk := func(field, against, ok, code string) {
b.WriteString(`<tr><td>` + field + `</td><td>` + against + `</td><td class="ok" style="color:#1a7f37">` + ok + `</td><td class="no" style="color:#b3261e">` + code + `</td></tr>`)
}
chk("Ценная бумага (ISIN / код НРД)", "справочник обслуживаемых выпусков", "выпуск обслуживаем", "не обслуживаем → <b>M2M05</b>")
chk("Допустимость к M2M (ISIN)", "правила сервиса МОСТ (только RU-ISIN)", "российский выпуск, допустим", "иностранный/недопустим → <b>M2M01</b>")
chk("Инвестор (ФИО + документ ДУЛ: тип, серия, номер)", "картотека клиентов", "клиент найден", "клиента нет → <b>M2M04</b>")
chk("Счёт депо зачисления", "счета клиента", "счёт определён", "счёт не определён → <b>M2M02</b>")
chk("Тип счёта депо", "признак «торговый»", "счёт торговый", "счёт неторговый → <b>M2M03</b>")
chk("Статус счёта депо", "блокировки/приостановки", "операции разрешены", "приостановлены → <b>M2M06</b>")
chk("Правовые ограничения по бумаге", "квалификация инвестора / закон РФ", "ограничений нет", "нарушение (напр. квалинвестор) → <b>M2M08</b>")
b.WriteString(`</table>`)
b.WriteString(`<p class="muted" style="font-size:12.5px">Если по всем строкам «совпало» — бумага <b>принимается</b> (Confirmation). Если хотя бы одна проверка не прошла — бумага <b>отклоняется</b> с первым сработавшим кодом. Для исходящих Fancy формирует запрос из этих же данных клиента.</p>`)
// Как отправляем через шлюз
b.WriteString(`<h2>Как именно отправляем через шлюз ИШ</h2>`)
b.WriteString(`<h3>Упаковка</h3><p>Документ (XML в windows-1251) кладётся в ZIP вместе с <code>config.xml</code>, где указан тип пакета. Для M2M-документов тип начинается с <code>#</code> (например <code>#M2MTD</code>). Для поручения депо — <code>&lt;package&gt;K&lt;/package&gt;</code> (тип ORDXM).</p>`)
b.WriteString(`<h3>Отправка</h3><p><code>POST /api/package/{канал}/file</code> (multipart, поле <code>File</code> = zip, <code>Type=ARCHIVE</code>) → ИШ возвращает id пакета. <b>Подпись ГОСТ ставит сам ИШ</b> — нам подписывать не нужно. Статус: <code>GET /api/package/status/{id}</code> (NEW → SENT → ERROR).</p>`)
b.WriteString(`<h3>Приём</h3><p>Входящие ИШ складывает в <code>INBOX</code> отдельными пакетами-папками. Мы читаем их (наш поллер), а также тянем через <code>GET /api/package?channel=&amp;type=</code> и <code>GET /api/package/{id}</code>.</p>`)
b.WriteString(`<div class="note"><b>Ключевое про 16 и 16/1.</b> Оба черновика приходят одним типом файла <code>..._MF010.xml</code>; отличаются <b>только</b> полем <code>&lt;order_t_id&gt;</code>: <code>16</code> — списание (наш исходящий), <code>16/1</code> — зачисление (наш входящий). Связь с M2M-сделкой — по <code>&lt;based_on&gt;</code> (GUID). Номер поручения — <code>&lt;contr_d_id&gt;</code>: по нему мы потом находим отчёт MS010. Подаются они одинаково — типом ЭД <b>ORDXM</b>.</div>`)
b.WriteString(`<p class="muted" style="font-size:11px;margin-top:16px">Дословные примеры файлов каждого шага — в этом же архиве (папки vhodyashie / ishodyashie).</p>`)
b.WriteString(`</body></html>`)
return []byte(b.String())
}
// pstep — шаг процесса с файлом, описанием действия и целевой системой.
type pstep struct{ who, text, href, dlabel, did, system string }
// processExample — конкретный завершённый пример перевода (по одному на сторону)
// для скачивания файлов каждого шага на странице процесса.
type processExample struct {
InID string
InHasReq, InHasDec, InHasDraft, InHasOrder, InHasMS010 bool
OutClaimID, OutGUID string
OutHasReq, OutHasResp, OutHasDec, OutHasDraft, OutHasOrder bool
OutHasMS010 bool
}
// buildProcessExample подбирает наиболее полный пример входящего и исходящего
// перевода и выясняет, какие файлы по нему доступны для скачивания.
func buildProcessExample(r *http.Request, h *setupHandlers) processExample {
ctx := r.Context()
var ex processExample
// Входящий: предпочитаем тот, где подано поручение (полная цепочка до MS010).
if globalIncomingStore != nil {
var best *IncomingTransfer
for _, it := range globalIncomingStore.List() {
if it.Status != "answered" {
continue
}
if it.OrderSubmitted {
best = it
break
}
if best == nil {
best = it
}
}
if best != nil {
ex.InID = best.ID
ex.InHasReq = best.RawXML != ""
ex.InHasDec = best.DecisionXML != ""
ex.InHasDraft = len(findMF010Files(best.GUID)) > 0
ex.InHasOrder = best.OrderXML != ""
ex.InHasMS010 = len(collectOrderResponseRaw(best.OrderContrDID)) > 0
}
}
// Исходящий: реальный тест-клиент, предпочитаем с черновиком 16 / поданным поручением.
if page, err := h.svc.ListClaims(ctx, m2mcore.Filter{Limit: 5000}); err == nil {
var best ClaimView
bestScore := -1
for _, v := range page.Items {
if !isTestClientDoc(v.Investor.Document.Series, v.Investor.Document.Number) {
continue
}
score := 0
g := string(v.M2MGUID)
if globalOutgoingStore != nil {
if _, ok := scanForm16Deal(globalOutgoingStore, g); ok {
score += 3
}
if sub, ok := globalOutgoingStore.Get(g); ok && sub.OrderXML != "" {
score += 3
}
}
if v.Status != "" {
score++
}
if score > bestScore {
best = v
bestScore = score
}
}
if bestScore >= 0 {
ex.OutClaimID = best.ID
ex.OutGUID = string(best.M2MGUID)
req, _ := h.svc.ClaimRawXML(ctx, best.ID, "request")
ex.OutHasReq = len(req) > 0
resp, _ := h.svc.ClaimRawXML(ctx, best.ID, "response")
ex.OutHasResp = len(resp) > 0
dec, _ := h.svc.ClaimRawXML(ctx, best.ID, "decision")
ex.OutHasDec = len(dec) > 0
if globalOutgoingStore != nil {
if _, ok := scanForm16Deal(globalOutgoingStore, ex.OutGUID); ok {
ex.OutHasDraft = true
}
if sub, ok := globalOutgoingStore.Get(ex.OutGUID); ok {
ex.OutHasOrder = sub.OrderXML != ""
ex.OutHasMS010 = len(collectOutgoingResponseRaw(sub.ContrDIDs)) > 0
}
}
}
}
return ex
}
func shortFolder(f string) string {
f = strings.TrimSuffix(f, "/")
if i := strings.LastIndex(f, "/"); i >= 0 {
return f[i+1:]
}
return f
}