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=). 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=&b=. 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=&b=", 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(``) b.WriteString(`Заявка M2M ` + esc(shortID(v.ID)) + ``) // Шапка с официальным логотипом сервиса MOEX МОСТ (НРД). b.WriteString(`
` + moexMostLogoSVG + `Сервис быстрых переводов ценных бумаг
MOEX МОСТ · НКО АО НРД
` + orgLogoIMG() + `
`) b.WriteString(`

Заявка на перевод ценных бумаг (M2M / MOEX МОСТ)

`) b.WriteString(`
Внутренний идентификатор bj: ` + esc(v.ID) + ` · Сформировано: ` + time.Now().In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + ` МСК
`) // Реквизиты заявки. b.WriteString(`

Реквизиты заявки

`) row := func(k, val string) { b.WriteString(``) } 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(`
` + k + `` + esc(val) + `
`) // Ценные бумаги. b.WriteString(`

Ценные бумаги

`) 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(``) } b.WriteString(`
Код/ISINКоличество
` + esc(code) + `` + esc(qty) + `
`) // Путь заявки (таймлайн). b.WriteString(`

Путь заявки (хронология)

    `) for _, e := range v.Timeline { line := e.Time.Format("02.01.2006 15:04:05") + " МСК — " + esc(e.Title) + "" if e.Detail != "" { line += `
    ` + esc(e.Detail) + `
    ` } b.WriteString(`
  1. ` + line + `
  2. `) } b.WriteString(`
`) // Итог НРД. if v.M2MDecision != nil || v.M2MResponse != nil { b.WriteString(`

Ответ НРД

`) if v.M2MResponse != nil { b.WriteString(`

Статус сервиса МОСТ: ` + esc(v.M2MResponse.StatusCode) + `

`) for _, e := range v.M2MResponse.Responses { b.WriteString(``) } b.WriteString(`
` + esc(e.Code) + `` + esc(m2mCodeText(e.Code)) + esc(e.Text) + `
`) } if v.M2MDecision != nil { b.WriteString(``) for _, s := range v.M2MDecision.Securities { b.WriteString(``) } b.WriteString(`
Референс ЦБРешение
` + esc(s.ReferenceID) + `` + esc(russianOutcome(s.Outcome)) + ` ` + esc(rejectCodesHuman(s.RejectCodes)) + `
`) } } // Дословные XML. if len(reqXML) > 0 { if fields := renderXMLFieldsTable(string(nsdxml.DecodeWindows1251(reqXML))); fields != "" { b.WriteString(`

Все поля запроса (разбор XML)

` + fields) } b.WriteString(`

Отправленный запрос в НРД (дословно)

Точная копия — в приложенном файле request.xml (кодировка windows-1251).

` +
			esc(string(nsdxml.DecodeWindows1251(reqXML))) + `
`) } if len(respXML) > 0 { b.WriteString(`

Ответ НРД (дословно)

Точная копия — в приложенном файле response.xml.

` +
			esc(string(nsdxml.DecodeWindows1251(respXML))) + `
`) } b.WriteString(`

Документ сформирован автоматически системой Bridge-and-Join-s (bj) на основании записей журнала обмена с НРД. ` + `Приложенные XML-файлы являются дословными копиями сообщений, которыми bj обменивался с сервисом MOEX МОСТ (НРД).

`) b.WriteString(``) 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 }