Files
zuevav 28c0ed7349 Документы НРД по поручению: папки с понятными названиями, опись и PDF
Пакеты НРД из INBOX ИШ (K…, 508…) распознаются по содержимому: квитанция о регистрации (или отказ),
уведомление GS036, отчёт о статусе GS116 (сквитовано / не сквитовано), отчёт об исполнении MS010,
ошибка. В ZIP ответа НРД и выгрузках для регулятора (16 и 16/1) — папки «01 Квитанция о регистрации
поручения — K1699092» и т. д., «Опись.txt» с назначением и реквизитами каждого документа и
«Документы НРД.pdf» (читаемое представление, печать через Chromium; без него — HTML). В карточке
перевода ответы НРД подписаны так же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:26:25 +03:00

231 lines
9.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
// m2mtest_bundle.go — единая выгрузка отчётов по проведённым тестам M2M одним ZIP:
// комплект доказательств по каждому пройденному кейсу (исходящие заявки + входящие
// переводы) плюс сопроводительная HTML-сводка «что провели за сегодня». Удобно
// отправить контрагенту, чтобы он проверил результаты тестирования.
import (
"archive/zip"
"bytes"
"fmt"
"html"
"net/http"
"strings"
"time"
)
// globalIncomingStore — стор входящих переводов для сводной выгрузки (ставится в server.go).
var globalIncomingStore *IncomingStore
// writeM2MReportBundle собирает ZIP: svodka.html + папка на каждый пройденный кейс.
func writeM2MReportBundle(w http.ResponseWriter, r *http.Request, h *setupHandlers) {
ctx := r.Context()
m2mMu.Lock()
plan := m2mLoadPlan()
m2mMu.Unlock()
// Входящие по GUID (id заявки == GUID, поэтому исходящие резолвим напрямую GetClaim).
incomingByGUID := map[string]*IncomingTransfer{}
if globalIncomingStore != nil {
for _, it := range globalIncomingStore.List() {
incomingByGUID[strings.ToUpper(strings.TrimSpace(it.GUID))] = it
}
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// Сопроводительная сводка.
if f, e := zw.Create("svodka.html"); e == nil {
_, _ = f.Write(m2mBundleCoverHTML(plan))
}
// Справка по кодам ошибок/отказов — чтобы контрагент понимал коды в отчётах.
if f, e := zw.Create("spravka-kody-oshibok.html"); e == nil {
_, _ = f.Write(m2mSpravkaHTML(true))
}
included := 0
seen := map[string]bool{}
// 1) Исходящие: по каждому инициированному нами кейсу (есть GUID в плане).
for _, c := range m2mCases {
st := plan.States[c.ID]
g := strings.TrimSpace(st.GUID)
gu := strings.ToUpper(g)
if g == "" || seen[gu] {
continue
}
folder := "ishodyashie/kejs-" + m2mSafeName(c.ID) + "/"
if v, err := h.svc.GetClaim(ctx, g); err == nil { // id заявки == GUID
v.Timeline = buildClaimTimeline(ctx, v)
writeClaimBundle(ctx, zw, h.svc, v, folder)
seen[gu] = true
included++
} else if it := incomingByGUID[gu]; it != nil {
writeIncomingBundleZip(zw, it, folder)
seen[gu] = true
included++
}
}
// 2) Входящие: все отвеченные переводы от контрагента по тестам (там же MS010).
cpCode := strings.ToUpper(strings.TrimSpace(plan.CounterpartyCode))
if globalIncomingStore != nil {
for _, it := range globalIncomingStore.List() {
g := strings.ToUpper(strings.TrimSpace(it.GUID))
if seen[g] || it.Status != "answered" {
continue
}
if cpCode != "" && strings.ToUpper(it.SenderCode) != cpCode {
continue // только от контрагента, кому шлём пакет
}
writeIncomingBundleZip(zw, it, "vhodyashie/perevod-"+m2mSafeName(it.ID)+"/")
seen[g] = true
included++
}
}
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fname := fmt.Sprintf("m2m-testy-%s-%s.zip", m2mSafeName(plan.CounterpartyCode), time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`)
_, _ = w.Write(buf.Bytes())
}
// writeIncomingBundleZip пишет комплект по входящему переводу в подпапку prefix
// (аналог exportIncoming): отчёт + request/decision + поручение 16/1 + ответ НРД.
func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string) {
if f, e := zw.Create(prefix + "otchet.html"); e == nil {
_, _ = f.Write(incomingReportHTML(it))
}
if it.RawXML != "" {
if f, e := zw.Create(prefix + "request.xml"); e == nil {
_, _ = f.Write(faithfulXMLBytes(it.RawXML))
}
}
if it.DecisionXML != "" {
if f, e := zw.Create(prefix + "decision.xml"); e == nil {
_, _ = f.Write(faithfulXMLBytes(it.DecisionXML))
}
}
if it.OrderXML != "" {
if f, e := zw.Create(prefix + "poruchenie-16-1.xml"); e == nil {
_, _ = f.Write(faithfulXMLBytes(it.OrderXML))
}
}
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
writeNSDDocsZip(zw, prefix+"Ответ НРД/", "Документы НРД по поручению 16/1 № "+it.OrderContrDID, docs, false)
}
}
// m2mBundleCoverHTML — сопроводительная сводка: что и с каким результатом провели.
func m2mBundleCoverHTML(plan m2mPlan) []byte {
esc := html.EscapeString
var b strings.Builder
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Сводка тестирования M2M</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.5}`)
b.WriteString(`h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:20px 0 8px;color:#1f4e79}`)
b.WriteString(`table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0}`)
b.WriteString(`td,th{border:1px solid #ccc;padding:6px 9px;text-align:left;vertical-align:top}th{background:#f0f0f0}`)
b.WriteString(`.ok{color:#1a7f37;font-weight:700}.no{color:#b3261e;font-weight:700}.wait{color:#b45309;font-weight:700}`)
b.WriteString(`.muted{color:#666}.mono{font-family:monospace;font-size:12px}</style></head><body>`)
b.WriteString(`<div style="border-bottom:2px solid #ff0508;padding-bottom:10px;margin-bottom:14px">`)
b.WriteString(`<div style="display:flex;align-items:center;gap:14px;margin-bottom:8px">` + moexMostLogoSVG + orgLogoIMG() + `</div>`)
b.WriteString(`<h1>Сводка тестирования переводов ценных бумаг (M2M / MOEX МОСТ)</h1>`)
b.WriteString(`<div class="muted">Сформировано: ` + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04") + ` МСК</div></div>`)
cp := plan.CounterpartyName
if cp == "" {
cp = plan.CounterpartyCode
}
b.WriteString(`<p>Контрагент по тестированию: <b>` + esc(cp) + `</b>`)
if plan.CounterpartyCode != "" {
b.WriteString(` (код <span class="mono">` + esc(plan.CounterpartyCode) + `</span>`)
if plan.CounterpartyINN != "" {
b.WriteString(`, ИНН ` + esc(plan.CounterpartyINN))
}
b.WriteString(`)`)
}
b.WriteString(`.</p>`)
// Считаем итоги.
var passed, failed, prog int
type row struct {
c m2mCase
st m2mCaseState
}
var rows []row
for _, c := range m2mCases {
st := plan.States[c.ID]
if st.Status == "" && st.GUID == "" {
continue // кейс не запускали
}
switch st.Status {
case "passed":
passed++
case "failed":
failed++
case "in_progress":
prog++
}
rows = append(rows, row{c, st})
}
b.WriteString(fmt.Sprintf(`<p>Итого прогнали: <b>%d</b> · <span class="ok">пройдено %d</span> · <span class="no">не прошло %d</span> · <span class="wait">в процессе %d</span>.</p>`,
len(rows), passed, failed, prog))
b.WriteString(`<h2>Проведённые кейсы</h2><table>`)
b.WriteString(`<tr><th>Кейс</th><th>Направление</th><th>Клиент</th><th>Бумаги</th><th>Ожидали</th><th>Итог</th><th>GUID / время</th></tr>`)
for _, rw := range rows {
c, st := rw.c, rw.st
dir := esc(c.Debit) + " → " + esc(c.Credit)
var secs []string
for _, s := range c.Secs {
secs = append(secs, esc(s.Code)+"×"+esc(s.Qty))
}
exp := "успех"
if c.Expect != "" {
exp = esc(m2mCodeLabel(c.Expect))
}
var res string
switch st.Status {
case "passed":
res = `<span class="ok">✓ пройдено</span>`
case "failed":
res = `<span class="no">✗ не прошло</span>`
case "in_progress":
res = `<span class="wait">⏳ в процессе</span>`
default:
res = `<span class="muted">—</span>`
}
gt := ""
if st.GUID != "" {
gt = `<span class="mono">` + esc(st.GUID) + `</span>`
}
if !st.Updated.IsZero() {
gt += `<div class="muted">` + st.Updated.In(time.FixedZone("МСК", 3*3600)).Format("02.01 15:04:05") + `</div>`
}
b.WriteString(`<tr><td><b>` + esc(c.ID) + `</b></td><td>` + dir + `</td><td>` + esc(c.Client) +
`</td><td style="font-size:12px">` + strings.Join(secs, "<br>") + `</td><td>` + exp + `</td><td>` + res +
`</td><td>` + gt + `</td></tr>`)
}
b.WriteString(`</table>`)
b.WriteString(`<p class="muted" style="font-size:12px;margin-top:16px">По каждому пройденному кейсу в этом архиве — отдельная папка <span class="mono">kejs-&lt;номер&gt;/</span> с отчётом-доказательством (otchet.html) и дословными XML запроса/ответа НРД. Для входящих также приложен ответ НРД (отчёт MS010) в папке <span class="mono">otvet-nrd/</span>.</p>`)
b.WriteString(`</body></html>`)
return []byte(b.String())
}
// m2mSafeName — безопасное имя папки/файла из id кейса или кода.
func m2mSafeName(s string) string {
s = strings.TrimSpace(s)
repl := func(r rune) rune {
switch r {
case '/', '\\', ' ', ':', '*', '?', '"', '<', '>', '|':
return '-'
}
return r
}
return strings.Map(repl, s)
}