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>
653 lines
42 KiB
Go
653 lines
42 KiB
Go
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=&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><order_t_id></code></td></tr>`)
|
||
b.WriteString(`<tr><td>Поручение депо</td><td class="mono">ORDXM</td><td>депозитарий → НРД</td><td>Поданный черновик 16 / 16/1 как классическое поручение (<code>config.xml <package>K</package></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><order_t_id>16/1</order_t_id></code>, <code><based_on></code>=GUID, <code><contr_d_id></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><package>K</package></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><order_t_id>16</order_t_id></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><package>K</package></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><package>K</package></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=&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><order_t_id></code>: <code>16</code> — списание (наш исходящий), <code>16/1</code> — зачисление (наш входящий). Связь с M2M-сделкой — по <code><based_on></code> (GUID). Номер поручения — <code><contr_d_id></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
|
||
}
|