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
+444
View File
@@ -0,0 +1,444 @@
package lkgateway
import (
"archive/zip"
"bytes"
"context"
"fmt"
"html"
"net/http"
"path"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// export.go — выгрузка доказательной базы по заявкам для контролирующих органов.
// • по одной заявке: ZIP {отчёт.html + оригинальные request.xml/response.xml};
// • по клиенту: ZIP {сводка.csv + папка на каждую заявку с тем же комплектом}.
// Оригинальные XML сохраняются в исходной кодировке windows-1251 — дословно как
// отправлено в НРД и получено от него (юридически точная копия).
// exportClaim отдаёт ZIP-пакет доказательств по одной заявке.
func (a *admin) exportClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
id = path.Base(id)
view, err := svc.GetClaim(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
view.Timeline = buildClaimTimeline(r.Context(), view)
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
writeClaimBundle(r.Context(), zw, svc, view, "")
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fname := fmt.Sprintf("zayavka-%s.zip", shortID(view.ID))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`)
_, _ = w.Write(buf.Bytes())
}
// exportClientClaims отдаёт ZIP со всеми заявками клиента (?investor=<id>).
func (a *admin) exportClientClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
investor := strings.TrimSpace(r.URL.Query().Get("investor"))
if investor == "" {
http.Error(w, "не указан клиент (investor)", http.StatusBadRequest)
return
}
filter := m2mcore.Filter{InvestorID: investor, Limit: 5000}
from, to := parseExportPeriod(r.URL.Query().Get("from"), r.URL.Query().Get("to"))
filter.CreatedFrom, filter.CreatedTo = from, to
pageData, err := svc.ListClaims(r.Context(), filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(pageData.Items) == 0 {
http.Error(w, "у клиента нет заявок за указанный период", http.StatusNotFound)
return
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// Сводка CSV по всем заявкам клиента.
var csv bytes.Buffer
csv.WriteString("\xEF\xBB\xBF") // BOM — чтобы Excel открыл в UTF-8
csv.WriteString("№ заявки;GUID НРД;Дата;Статус;ФИО инвестора;Передающий ИНН;Зачисляющий ИНН;Бумаг;Итог НРД\n")
for _, v := range pageData.Items {
csv.WriteString(strings.Join([]string{
csvCell(v.ID), csvCell(string(v.M2MGUID)),
csvCell(v.CreatedAt.Format("02.01.2006 15:04:05")),
csvCell(russianState(v.Status)),
csvCell(fio(v.Investor)),
csvCell(v.TransferringDepositoryINN), csvCell(v.ReceivingDepositoryINN),
csvCell(fmt.Sprintf("%d", len(v.Securities))),
csvCell(nsdOutcomeSummary(v)),
}, ";") + "\n")
}
if f, e := zw.Create("svodka.csv"); e == nil {
_, _ = f.Write(csv.Bytes())
}
// Полный комплект по каждой заявке — в отдельной папке.
for _, v := range pageData.Items {
v.Timeline = buildClaimTimeline(r.Context(), v)
writeClaimBundle(r.Context(), zw, svc, v, "zayavka-"+shortID(v.ID)+"/")
}
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
period := ""
if from != nil {
period = "-s-" + from.Format("2006-01-02")
}
if to != nil {
period += "-po-" + to.Format("2006-01-02")
}
fname := fmt.Sprintf("klient-%s-zayavki%s.zip", shortID(investor), period)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`)
_, _ = w.Write(buf.Bytes())
}
// parseExportPeriod разбирает даты периода (YYYY-MM-DD). Пустые → nil (без
// ограничения с этой стороны). Конец периода — включительно (до конца суток).
func parseExportPeriod(fromStr, toStr string) (*time.Time, *time.Time) {
var from, to *time.Time
if t, err := time.Parse("2006-01-02", strings.TrimSpace(fromStr)); err == nil {
from = &t
}
if t, err := time.Parse("2006-01-02", strings.TrimSpace(toStr)); err == nil {
end := t.Add(24*time.Hour - time.Second) // конец суток включительно
to = &end
}
return from, to
}
// writeClaimBundle пишет в zip комплект по одной заявке: отчёт + оригинальные XML
// (request, а также response ИЛИ decision — что есть).
func writeClaimBundle(ctx context.Context, zw *zip.Writer, svc *Service, v ClaimView, prefix string) {
reqXML, _ := svc.ClaimRawXML(ctx, v.ID, "request")
respXML, _ := svc.ClaimRawXML(ctx, v.ID, "response")
decXML, _ := svc.ClaimRawXML(ctx, v.ID, "decision")
if f, e := zw.Create(prefix + "otchet.html"); e == nil {
outXML := respXML
if len(outXML) == 0 {
outXML = decXML
}
_, _ = f.Write(claimReportHTML(v, reqXML, outXML))
}
if len(reqXML) > 0 {
if f, e := zw.Create(prefix + "request.xml"); e == nil {
_, _ = f.Write(reqXML)
}
}
if len(respXML) > 0 {
if f, e := zw.Create(prefix + "response.xml"); e == nil {
_, _ = f.Write(respXML)
}
}
if len(decXML) > 0 {
if f, e := zw.Create(prefix + "decision.xml"); e == nil {
_, _ = f.Write(decXML)
}
}
}
// exportCompare — ZIP-сравнение двух заявок для обращения в НРД: сопроводительное
// письмо + комплект по каждой заявке. Параметры ?a=<id>&b=<id>.
func (a *admin) exportCompare(w http.ResponseWriter, r *http.Request, svc *Service) {
idA := strings.TrimSpace(r.URL.Query().Get("a"))
idB := strings.TrimSpace(r.URL.Query().Get("b"))
if idA == "" || idB == "" {
http.Error(w, "нужны обе заявки: ?a=<id>&b=<id>", http.StatusBadRequest)
return
}
va, err := svc.GetClaim(r.Context(), idA)
if err != nil {
http.Error(w, "заявка A: "+err.Error(), http.StatusNotFound)
return
}
vb, err := svc.GetClaim(r.Context(), idB)
if err != nil {
http.Error(w, "заявка B: "+err.Error(), http.StatusNotFound)
return
}
va.Timeline = buildClaimTimeline(r.Context(), va)
vb.Timeline = buildClaimTimeline(r.Context(), vb)
// Идентичны ли запросы (без учёта GUID/timestamp/референсов)?
reqA, _ := svc.ClaimRawXML(r.Context(), va.ID, "request")
reqB, _ := svc.ClaimRawXML(r.Context(), vb.ID, "request")
identical := normalizeReq(reqA) == normalizeReq(reqB) && len(reqA) > 0
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
if f, e := zw.Create("soprovoditelnoe-pismo.txt"); e == nil {
_, _ = f.Write(compareNote(va, vb, identical))
}
writeClaimBundle(r.Context(), zw, svc, va, "zayavka-A-"+shortID(va.ID)+"/")
writeClaimBundle(r.Context(), zw, svc, vb, "zayavka-B-"+shortID(vb.ID)+"/")
if err := zw.Close(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fname := fmt.Sprintf("sravnenie-%s-vs-%s.zip", shortID(va.ID), shortID(vb.ID))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+fname+`"`)
_, _ = w.Write(buf.Bytes())
}
// normalizeReq убирает содержимое уникальных полей (GUID/timestamp/референсы),
// чтобы сравнивать запросы по существу.
func normalizeReq(xml []byte) string {
s := string(nsdxml.DecodeWindows1251(xml))
for _, tag := range []string{"GUID", "CreationTimestamp", "ReferenceId", "ReferenceID"} {
s = stripTagContent(s, tag)
}
return s
}
// stripTagContent очищает текст между открывающим тегом, оканчивающимся на
// "tag>", и следующим "<" (т.е. содержимое элемента) — для всех вхождений.
func stripTagContent(s, tag string) string {
needle := tag + ">"
var b strings.Builder
for {
i := strings.Index(s, needle)
if i < 0 {
b.WriteString(s)
break
}
cut := i + len(needle)
b.WriteString(s[:cut]) // до конца открывающего тега включительно
rest := s[cut:]
if j := strings.IndexByte(rest, '<'); j >= 0 {
s = rest[j:] // пропускаем содержимое до следующего тега
} else {
s = rest
}
}
return b.String()
}
// compareNote формирует сопроводительное письмо для НРД.
func compareNote(va, vb ClaimView, identical bool) []byte {
var b strings.Builder
b.WriteString("Обращение в НРД (сервис MOEX МОСТ, M2M) — сравнение двух заявок\n")
b.WriteString("=================================================================\n\n")
if identical {
b.WriteString("Ситуация: идентичный по составу запрос M2MTransferRequest ранее был\n")
b.WriteString("ПОДТВЕРЖДЁН, а теперь ОТКЛОНЯЕТСЯ. Просим пояснить причину и как её устранить.\n\n")
} else {
b.WriteString("Ситуация: сравнение двух заявок M2MTransferRequest с разным результатом.\n\n")
}
writeOne := func(tag string, v ClaimView) {
b.WriteString(tag + "\n")
b.WriteString(" Внутренний ID bj : " + v.ID + "\n")
b.WriteString(" GUID сообщения : " + string(v.M2MGUID) + "\n")
b.WriteString(" Дата отправки : " + v.CreatedAt.Format("02.01.2006 15:04:05") + " МСК\n")
b.WriteString(" Статус : " + russianState(v.Status) + "\n")
b.WriteString(" Инвестор : " + fio(v.Investor) + "\n")
b.WriteString(" Депозитарии : передающий ИНН " + v.TransferringDepositoryINN + " → зачисляющий ИНН " + v.ReceivingDepositoryINN + "\n")
b.WriteString(" Итог НРД : " + nsdOutcomeSummary(v) + "\n")
if v.M2MResponse != nil {
for _, e := range v.M2MResponse.Responses {
if e.Code != "" {
b.WriteString(" код " + e.Code + " — " + m2mCodeText(e.Code) + e.Text + "\n")
}
}
}
b.WriteString("\n")
}
writeOne("ЗАЯВКА A:", va)
writeOne("ЗАЯВКА B:", vb)
if identical {
b.WriteString("ВАЖНО: приложенные request.xml обеих заявок ПОЛНОСТЬЮ СОВПАДАЮТ по составу\n")
b.WriteString("(отличаются только уникальные GUID, CreationTimestamp и ReferenceId, что нормально).\n")
b.WriteString("Состав, реквизиты депозитариев, ценные бумаги и место расчётов — идентичны.\n\n")
}
b.WriteString("Приложения: по каждой заявке — request.xml (отправленный запрос) и\n")
b.WriteString("response.xml/decision.xml (ответ/решение НРД), а также отчёт otchet.html.\n")
b.WriteString("Все XML — в исходной кодировке windows-1251, дословно.\n\n")
b.WriteString("Просьба: пояснить причину изменения результата и порядок устранения\n")
b.WriteString("(в т.ч. если это связано с учётом тестовых ценных бумаг на стороне НРД).\n")
return []byte(b.String())
}
// claimReportHTML формирует печатный отчёт-доказательство по заявке (self-contained,
// UTF-8). Сырые XML показаны дословно (декодированы из windows-1251 для чтения);
// юридически точные копии — в приложенных .xml.
func claimReportHTML(v ClaimView, reqXML, respXML []byte) []byte {
var b strings.Builder
esc := html.EscapeString
b.WriteString(`<!doctype html><html lang="ru"><head><meta charset="utf-8">`)
b.WriteString(`<title>Заявка M2M ` + esc(shortID(v.ID)) + `</title><style>`)
b.WriteString(`body{font:14px/1.5 -apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#111;max-width:900px;margin:24px auto;padding:0 20px}`)
b.WriteString(`h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:26px 0 8px;border-bottom:1px solid #ddd;padding-bottom:4px}`)
b.WriteString(`table{border-collapse:collapse;width:100%;margin:6px 0}td,th{border:1px solid #ccc;padding:6px 9px;text-align:left;vertical-align:top;font-size:13px}`)
b.WriteString(`th{background:#f3f4f6;width:34%}.muted{color:#666}.tl{margin:6px 0}.tl li{margin:4px 0}`)
b.WriteString(`pre{background:#f6f8fa;border:1px solid #ddd;border-radius:6px;padding:12px;overflow:auto;font-size:12px;white-space:pre-wrap;word-break:break-all}`)
b.WriteString(`.seal{margin-top:8px;color:#666;font-size:12px}`)
// Охранное поле логотипа MOEX МОСТ (руководство НРД): отступ вокруг + низ.
b.WriteString(`.brand{display:flex;align-items:center;gap:14px;padding:6px 0 14px;border-bottom:2px solid #ff0508;margin-bottom:16px}`)
b.WriteString(`.brand svg{display:block;flex:none}.brand .svc{font-size:12px;color:#666;line-height:1.3}`)
b.WriteString(`@media print{body{margin:0}}</style></head><body>`)
// Шапка с официальным логотипом сервиса MOEX МОСТ (НРД).
b.WriteString(`<div class="brand">` + moexMostLogoSVG +
`<span class="svc">Сервис быстрых переводов ценных бумаг<br>MOEX МОСТ · НКО АО НРД</span>` + orgLogoIMG() + `</div>`)
b.WriteString(`<h1>Заявка на перевод ценных бумаг (M2M / MOEX МОСТ)</h1>`)
b.WriteString(`<div class="muted">Внутренний идентификатор bj: ` + esc(v.ID) + ` · Сформировано: ` +
time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + ` МСК</div>`)
// Реквизиты заявки.
b.WriteString(`<h2>Реквизиты заявки</h2><table>`)
row := func(k, val string) { b.WriteString(`<tr><th>` + k + `</th><td>` + esc(val) + `</td></tr>`) }
row("GUID сообщения в НРД", string(v.M2MGUID))
if lkID, ok := LKClaimIDByGUID(string(v.M2MGUID)); ok {
row("Происхождение", fmt.Sprintf("Личный кабинет ЕСИА Финанс, заявка №%d", lkID))
}
row("Статус", russianState(v.Status))
row("Создана", v.CreatedAt.Format("02.01.2006 15:04:05"))
row("Обновлена", v.UpdatedAt.Format("02.01.2006 15:04:05"))
row("Инвестор", fio(v.Investor))
row("Документ инвестора", docLabel(v.Investor.Document))
row("Передающий депозитарий (ИНН)", v.TransferringDepositoryINN)
row("Зачисляющий депозитарий (ИНН)", v.ReceivingDepositoryINN)
b.WriteString(`</table>`)
// Ценные бумаги.
b.WriteString(`<h2>Ценные бумаги</h2><table><tr><th style="width:auto">Код/ISIN</th><th style="width:auto">Количество</th></tr>`)
for _, s := range v.Securities {
code := s.SecurityDetails.ISIN
if code == "" {
code = s.SecurityCode
}
qty := fmt.Sprintf("%d", s.Quantity.Whole)
if s.Quantity.Fractional != "" {
qty = s.Quantity.Fractional
}
b.WriteString(`<tr><td>` + esc(code) + `</td><td>` + esc(qty) + `</td></tr>`)
}
b.WriteString(`</table>`)
// Путь заявки (таймлайн).
b.WriteString(`<h2>Путь заявки (хронология)</h2><ol class="tl">`)
for _, e := range v.Timeline {
line := e.Time.Format("02.01.2006 15:04:05") + " МСК — <b>" + esc(e.Title) + "</b>"
if e.Detail != "" {
line += `<div class="muted">` + esc(e.Detail) + `</div>`
}
b.WriteString(`<li>` + line + `</li>`)
}
b.WriteString(`</ol>`)
// Итог НРД.
if v.M2MDecision != nil || v.M2MResponse != nil {
b.WriteString(`<h2>Ответ НРД</h2>`)
if v.M2MResponse != nil {
b.WriteString(`<p>Статус сервиса МОСТ: <b>` + esc(v.M2MResponse.StatusCode) + `</b></p><table>`)
for _, e := range v.M2MResponse.Responses {
b.WriteString(`<tr><th style="width:20%">` + esc(e.Code) + `</th><td>` + esc(m2mCodeText(e.Code)) + esc(e.Text) + `</td></tr>`)
}
b.WriteString(`</table>`)
}
if v.M2MDecision != nil {
b.WriteString(`<table><tr><th style="width:auto">Референс ЦБ</th><th style="width:auto">Решение</th></tr>`)
for _, s := range v.M2MDecision.Securities {
b.WriteString(`<tr><td>` + esc(s.ReferenceID) + `</td><td>` + esc(russianOutcome(s.Outcome)) + ` ` + esc(rejectCodesHuman(s.RejectCodes)) + `</td></tr>`)
}
b.WriteString(`</table>`)
}
}
// Дословные XML.
if len(reqXML) > 0 {
if fields := renderXMLFieldsTable(string(nsdxml.DecodeWindows1251(reqXML))); fields != "" {
b.WriteString(`<h2>Все поля запроса (разбор XML)</h2>` + fields)
}
b.WriteString(`<h2>Отправленный запрос в НРД (дословно)</h2><p class="muted">Точная копия — в приложенном файле <code>request.xml</code> (кодировка windows-1251).</p><pre>` +
esc(string(nsdxml.DecodeWindows1251(reqXML))) + `</pre>`)
}
if len(respXML) > 0 {
b.WriteString(`<h2>Ответ НРД (дословно)</h2><p class="muted">Точная копия — в приложенном файле <code>response.xml</code>.</p><pre>` +
esc(string(nsdxml.DecodeWindows1251(respXML))) + `</pre>`)
}
b.WriteString(`<p class="seal">Документ сформирован автоматически системой Bridge-and-Join-s (bj) на основании записей журнала обмена с НРД. ` +
`Приложенные XML-файлы являются дословными копиями сообщений, которыми bj обменивался с сервисом MOEX МОСТ (НРД).</p>`)
b.WriteString(`</body></html>`)
return []byte(b.String())
}
// _ = context пометка не нужна — context используется в сигнатурах.
// --- вспомогательное ---
func shortID(id string) string {
if i := strings.IndexByte(id, '-'); i > 0 {
return id[:i]
}
if len(id) > 8 {
return id[:8]
}
return id
}
func fio(inv Investor) string {
return strings.TrimSpace(inv.LastName + " " + inv.FirstName + " " + inv.MiddleName)
}
func docLabel(d Document) string {
t := d.DocumentType
if t == "21" {
t = "Паспорт РФ"
}
s := t
if d.Series != "" {
s += " серия " + d.Series
}
if d.Number != "" {
s += " № " + d.Number
}
return strings.TrimSpace(s)
}
func nsdOutcomeSummary(v ClaimView) string {
if v.M2MDecision != nil {
conf, rej := 0, 0
for _, s := range v.M2MDecision.Securities {
if s.Outcome == "confirmed" {
conf++
} else {
rej++
}
}
return fmt.Sprintf("Решение: принято %d, отклонено %d", conf, rej)
}
if v.M2MResponse != nil {
var codes []string
for _, e := range v.M2MResponse.Responses {
if e.Code != "" {
codes = append(codes, e.Code)
}
}
return v.M2MResponse.StatusCode + " " + strings.Join(codes, " ")
}
return "—"
}
func csvCell(s string) string {
if strings.ContainsAny(s, ";\"\n") {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return s
}