Files
Bridge-and-Join-s/internal/lkgateway/fancy_bundle.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

653 lines
42 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
// 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
}