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

233 lines
9.5 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
// 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))
}
}
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
if f, e := zw.Create(prefix + "otvet-nrd/" + rf.Name); e == nil {
_, _ = f.Write(rf.Data)
}
}
}
// 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)
}