Files

445 lines
20 KiB
Go
Raw Permalink 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
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
}