28c0ed7349
Пакеты НРД из 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>
231 lines
9.5 KiB
Go
231 lines
9.5 KiB
Go
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-<номер>/</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)
|
||
}
|