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