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>
This commit is contained in:
zuevav
2026-09-15 19:41:12 +03:00
parent 59fc774f08
commit da12a9ec1c
132 changed files with 29969 additions and 1089 deletions
+232
View File
@@ -0,0 +1,232 @@
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)
}