diff --git a/internal/lkgateway/incoming_export.go b/internal/lkgateway/incoming_export.go index 4332219..ac644ad 100644 --- a/internal/lkgateway/incoming_export.go +++ b/internal/lkgateway/incoming_export.go @@ -45,10 +45,8 @@ func exportIncoming(w http.ResponseWriter, r *http.Request, deps incomingDeps, i } } // Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX. - for _, rf := range collectOrderResponseRaw(it.OrderContrDID) { - if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil { - _, _ = f.Write(rf.Data) - } + if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 { + writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16/1 (зачисление) № "+it.OrderContrDID, docs, true) } } diff --git a/internal/lkgateway/incoming_order.go b/internal/lkgateway/incoming_order.go index 0fe07d2..0eaa776 100644 --- a/internal/lkgateway/incoming_order.go +++ b/internal/lkgateway/incoming_order.go @@ -139,8 +139,8 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming // writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД // (отчёт MS010 + квитанции) по поручению с данным contr_d_id. func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) { - files := collectOrderResponseRaw(contrDID) - if len(files) == 0 { + docs := nsdOrderDocs(contrDID) + if len(docs) == 0 { http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound) return } @@ -148,11 +148,7 @@ func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) { w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname)) zw := zip.NewWriter(w) defer zw.Close() - for _, f := range files { - if wf, e := zw.Create(f.Name); e == nil { - _, _ = wf.Write(f.Data) - } - } + writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true) } // downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1 @@ -360,8 +356,9 @@ func orderRespKind(pkg, body string) (string, bool) { u := strings.ToUpper(body) low := strings.ToLower(body) switch { - case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025"): - return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true + case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025") || strings.Contains(u, "SESE.024") || strings.Contains(u, "GS036") || strings.Contains(u, "GS116") || strings.Contains(low, ""): + d := classifyNSDDoc(pkg, body) + return d.Title + " — " + d.About, d.Done case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"): return "Ошибка обработки пакета (CERROR)", false case strings.Contains(u, "REORD") || strings.Contains(low, "принят"): diff --git a/internal/lkgateway/m2mtest_bundle.go b/internal/lkgateway/m2mtest_bundle.go index 3381e46..73ed1ed 100644 --- a/internal/lkgateway/m2mtest_bundle.go +++ b/internal/lkgateway/m2mtest_bundle.go @@ -115,10 +115,8 @@ func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string) _, _ = 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) - } + if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 { + writeNSDDocsZip(zw, prefix+"Ответ НРД/", "Документы НРД по поручению 16/1 № "+it.OrderContrDID, docs, false) } } diff --git a/internal/lkgateway/nsddocs.go b/internal/lkgateway/nsddocs.go new file mode 100644 index 0000000..fc69623 --- /dev/null +++ b/internal/lkgateway/nsddocs.go @@ -0,0 +1,363 @@ +package lkgateway + +// nsddocs.go — документы НРД по депо-поручению в понятном виде. Пакеты в INBOX ИШ называются +// номерами (K1699092, 50818781 …); по содержимому определяем, что это за документ (квитанция о +// регистрации, уведомление GS036, отчёт о статусе GS116, отчёт об исполнении MS010, ошибка) и +// выгружаем папками с названием, описью и PDF для депозитария (как печатные формы из канала НРД). + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "html" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// nsdDoc — один документ (пакет) НРД по поручению. +type nsdDoc struct { + Pkg string + Time time.Time + Code string // REG | REG_REJECT | GS036 | GS116 | MS010 | ERROR | "" + Title string + About string + Fields [][2]string + Done bool // отчёт об исполнении (MS010) + Rejected bool + Files []respFile // имена без папки пакета +} + +var nsdTagCache sync.Map // имя элемента → *regexp.Regexp + +// xmlTag — первое значение элемента name (без учёта префикса пространства имён). +func xmlTag(body, name string) string { + v, ok := nsdTagCache.Load(name) + if !ok { + v, _ = nsdTagCache.LoadOrStore(name, regexp.MustCompile(`<(?:\w+:)?`+name+`(?:\s[^>]*)?>\s*([^<]*?)\s*`)) + } + if m := v.(*regexp.Regexp).FindStringSubmatch(body); m != nil { + return html.UnescapeString(m[1]) + } + return "" +} + +func addField(d *nsdDoc, k, v string) { + if strings.TrimSpace(v) != "" { + d.Fields = append(d.Fields, [2]string{k, strings.TrimSpace(v)}) + } +} + +var ( + othrIDRe = regexp.MustCompile(`(?s)\s*([^<]+)`) + sfkpgAcctRe = regexp.MustCompile(`(?s)\s*([^<]+)`) + gs036PairRe = regexp.MustCompile(`(?s)\s*(?:([^<]*))?\s*(?:([^<]*))?\s*`) + prtryIDRe = regexp.MustCompile(`(?s)<(Rcvg|Dlvrg)SttlmPties>.*?\s*([^<]+)`) + qtyUnitRe = regexp.MustCompile(`(?s)<(?:SttldQty|SttlmQty)>\s*\s*<(?:Unit|FaceAmt|AmtsdVal)>([^<]+)<`) + dateInRe = regexp.MustCompile(`(?s)<(FctvSttlmDt|SttlmDt)>\s*
\s*
([^<]+)
`) +) + +func securityLine(body string) string { + code, isin, desc := "", xmlTag(body, "ISIN"), xmlTag(body, "Desc") + if m := othrIDRe.FindStringSubmatch(body); m != nil { + code = m[1] + } + parts := []string{} + for _, p := range []string{code, isin, desc} { + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, " · ") +} + +// classifyNSDDoc — что за документ НРД в пакете pkg (текст всех файлов пакета — body). +func classifyNSDDoc(pkg, body string) nsdDoc { + d := nsdDoc{Pkg: pkg} + biz := xmlTag(body, "BizSvc") + up := strings.ToUpper(body) + switch { + case biz == "MS010" || strings.Contains(body, "sese.025"): + d.Code, d.Done = "MS010", true + op := map[string]string{"DELI": "списание", "RECE": "зачисление"}[xmlTag(body, "SctiesMvmntTp")] + d.Title = "Отчёт об исполнении поручения (MS010)" + d.About = "Итоговый документ: НРД исполнил депо-поручение" + pick3(op != "", " — "+op+" бумаг проведено", "") + "." + addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId")) + addField(&d, "Референс сделки M2M", xmlTag(body, "TradId")) + addField(&d, "Операция", op) + addField(&d, "Ценная бумага", securityLine(body)) + if m := qtyUnitRe.FindStringSubmatch(body); m != nil { + addField(&d, "Количество", m[1]) + } + if m := sfkpgAcctRe.FindStringSubmatch(body); m != nil { + addField(&d, "Счёт депо", m[1]) + } + if m := dateInRe.FindStringSubmatch(body); m != nil { + addField(&d, "Дата расчётов", m[2]) + } + case biz == "GS116" || strings.Contains(body, "sese.024"): + d.Code = "GS116" + d.Title = "Отчёт о статусе поручения (GS116)" + switch { + case strings.Contains(body, ""): + d.About = "Поручение сквитовано со встречным поручением контрагента (MACH) — НРД переходит к исполнению." + addField(&d, "Статус сверки", "сквитовано (MACH)") + case strings.Contains(body, ""): + d.About = "Поручение не сквитовано: встречное поручение контрагента не найдено или не совпадает по реквизитам." + addField(&d, "Статус сверки", "не сквитовано") + default: + d.About = "Промежуточный отчёт НРД о статусе поручения." + } + addField(&d, "№ поручения", xmlTag(body, "AcctOwnrTxId")) + addField(&d, "Рег. № в НРД", xmlTag(body, "AcctSvcrTxId")) + addField(&d, "Ценная бумага", securityLine(body)) + if m := qtyUnitRe.FindStringSubmatch(body); m != nil { + addField(&d, "Количество", m[1]) + } + if m := prtryIDRe.FindStringSubmatch(body); m != nil { + addField(&d, pick3(m[1] == "Rcvg", "Получатель", "Отправитель"), m[2]) + } + if m := dateInRe.FindStringSubmatch(body); m != nil { + addField(&d, "Дата расчётов", m[2]) + } + case biz == "GS036" || strings.Contains(body, "gs036"): + d.Code = "GS036" + subject := "" + for _, m := range gs036PairRe.FindAllStringSubmatch(body, -1) { + hdr, txt := strings.TrimSpace(html.UnescapeString(m[1])), strings.TrimSpace(html.UnescapeString(m[2])) + switch { + case hdr == "" && txt != "" && subject == "": + subject = txt + case hdr != "": + addField(&d, hdr, txt) + } + } + d.Title = "Уведомление НРД (GS036)" + if subject = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(subject, "об "), "о ")); subject != "" { + prep := "о " + if r := []rune(subject); strings.ContainsRune("аоуэиыАОУЭИЫ", r[0]) { + prep = "об " + } + d.Title = "Уведомление " + prep + strings.TrimSuffix(subject, " к поручению депо") + " (GS036)" + } + d.About = "Промежуточное уведомление по поручению; итог — в отчёте о статусе (GS116) и об исполнении (MS010)." + case strings.Contains(body, "") || strings.Contains(body, ""): + abort, cause := xmlTag(body, "abort_code"), xmlTag(body, "abort_cause") + if abort != "" || cause != "" { + d.Code, d.Rejected = "REG_REJECT", true + d.Title = "Отказ в регистрации поручения" + d.About = "НРД не принял поручение к исполнению: " + strings.TrimSpace(abort+" "+cause) + "." + } else { + d.Code = "REG" + d.Title = "Квитанция о регистрации поручения" + d.About = "НРД принял поручение к обработке и присвоил регистрационный номер." + } + addField(&d, "Рег. № в НРД", xmlTag(body, "reg_no")) + addField(&d, "Дата регистрации", xmlTag(body, "reg_date")) + addField(&d, "№ поручения", xmlTag(body, "contragent_doc_id")) + addField(&d, "Код отказа", abort) + addField(&d, "Причина отказа", cause) + case strings.Contains(up, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(strings.ToLower(body), "ошибк"): + d.Code, d.Rejected = "ERROR", true + d.Title = "Ошибка обработки пакета" + d.About = "НРД не смог обработать пакет: " + orderRespSummary(body) + case biz != "": + d.Title = "Отчёт НРД (" + biz + ")" + d.About = "Документ НРД по поручению." + default: + d.Title = "Документ НРД" + d.About = "Документ НРД по поручению (тип не распознан — см. файлы в папке)." + } + return d +} + +// pkgFiles — файлы пакета (xml/rpt/htm/dbf/sgn, содержимое zip) без имени папки. +func pkgFiles(dir string) []respFile { + var out []respFile + files, _ := os.ReadDir(dir) + for _, f := range files { + if f.IsDir() { + continue + } + p := filepath.Join(dir, f.Name()) + low := strings.ToLower(f.Name()) + switch { + case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".rpt"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".dbf"), strings.HasSuffix(low, ".sgn"): + if b, e := os.ReadFile(p); e == nil { + out = append(out, respFile{Name: f.Name(), Data: b}) + } + case strings.HasSuffix(low, ".zip"): + if b, e := os.ReadFile(p); e == nil { + if zr, e2 := zip.NewReader(bytes.NewReader(b), int64(len(b))); e2 == nil { + for _, zf := range zr.File { + if rc, e3 := zf.Open(); e3 == nil { + data, _ := io.ReadAll(rc) + rc.Close() + out = append(out, respFile{Name: zf.Name, Data: data}) + } + } + } + } + } + } + return out +} + +// nsdOrderDocs — документы НРД по поручениям (contr_d_id), по времени. +func nsdOrderDocs(contrDIDs ...string) []nsdDoc { + entries, err := os.ReadDir(igwInboxDir) + if err != nil { + return nil + } + seen := map[string]bool{} + var out []nsdDoc + for _, cd := range contrDIDs { + cd = strings.TrimSpace(cd) + if cd == "" { + continue + } + for _, e := range entries { + dir := e.Name() + if !e.IsDir() || strings.HasPrefix(dir, "#") || dir == "INBOX" || seen[dir] { + continue + } + full := filepath.Join(igwInboxDir, dir) + body, mt := readPkgText(full) + if body == "" || !strings.Contains(body, cd) { + continue + } + seen[dir] = true + d := classifyNSDDoc(dir, body) + d.Time, d.Files = mt, pkgFiles(full) + out = append(out, d) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) }) + return out +} + +var folderBadChars = strings.NewReplacer("/", "-", `\`, "-", ":", " ", "*", "", "?", "", `"`, "", "<", "", ">", "", "|", "") + +// nsdDocFolder — «02 Уведомление об отсутствии встречного поручения (GS036) — 50818777». +func nsdDocFolder(i int, d nsdDoc) string { + return folderBadChars.Replace(fmt.Sprintf("%02d %s — %s", i+1, d.Title, d.Pkg)) +} + +func mskTime(t time.Time) string { + if t.IsZero() { + return "—" + } + return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05") + " МСК" +} + +// writeNSDDocsZip — документы НРД папками с понятными названиями + «Опись.txt» + PDF (если withPDF). +func writeNSDDocsZip(zw *zip.Writer, prefix, title string, docs []nsdDoc, withPDF bool) { + var opis strings.Builder + opis.WriteString(title + "\r\n") + opis.WriteString("Сформировано bj " + mskTime(time.Now()) + ". Папки — документы НРД в порядке поступления; внутри — подлинные файлы из INBOX ИШ (XML, печатная форма .rpt, таблицы .dbf, подпись .SGN).\r\n\r\n") + for i, d := range docs { + folder := nsdDocFolder(i, d) + for _, f := range d.Files { + if w, e := zw.Create(prefix + folder + "/" + f.Name); e == nil { + _, _ = w.Write(f.Data) + } + } + fmt.Fprintf(&opis, "%s\r\n Получен: %s\r\n Что это: %s\r\n", folder, mskTime(d.Time), d.About) + for _, kv := range d.Fields { + fmt.Fprintf(&opis, " %s: %s\r\n", kv[0], kv[1]) + } + opis.WriteString("\r\n") + } + if w, e := zw.Create(prefix + "Опись.txt"); e == nil { + _, _ = w.Write([]byte("\ufeff" + opis.String())) + } + if !withPDF { + return + } + page := nsdDocsReportHTML(title, docs) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if pdf, err := htmlToPDF(ctx, page); err == nil { + if w, e := zw.Create(prefix + "Документы НРД.pdf"); e == nil { + _, _ = w.Write(pdf) + } + return + } else { + log.Printf("nsd-docs: PDF не собран (%v) — в архиве HTML", err) + } + if w, e := zw.Create(prefix + "Документы НРД.html"); e == nil { + _, _ = w.Write(page) + } +} + +// nsdDocsReportHTML — читаемая сводка документов НРД (для PDF). +func nsdDocsReportHTML(title string, docs []nsdDoc) []byte { + esc := html.EscapeString + var b strings.Builder + b.WriteString(`` + esc(title) + ``) + b.WriteString(`

` + esc(title) + `

Документы НРД по депо-поручению в порядке поступления · сформировано bj ` + esc(mskTime(time.Now())) + `

`) + for i, d := range docs { + cls := "" + switch { + case d.Done: + cls = "ok" + case d.Rejected: + cls = "bad" + } + fmt.Fprintf(&b, `

%d. %s

пакет НРД %s · получен %s
%s
`, + cls, i+1, esc(d.Title), esc(d.Pkg), esc(mskTime(d.Time)), esc(d.About)) + if len(d.Fields) > 0 { + b.WriteString("") + for _, kv := range d.Fields { + fmt.Fprintf(&b, ``, esc(kv[0]), esc(kv[1])) + } + b.WriteString("
%s%s
") + } + b.WriteString("
") + } + b.WriteString(`

Подлинные документы — файлы в папках архива (XML с подписью НРД .SGN). Этот PDF — их читаемое представление.

`) + return []byte(b.String()) +} + +// htmlToPDF — печать HTML в PDF headless-браузером (Chromium есть на серверах bj с Astra). +func htmlToPDF(ctx context.Context, page []byte) ([]byte, error) { + bin := "" + for _, name := range []string{"chromium", "chromium-browser", "google-chrome"} { + if p, err := exec.LookPath(name); err == nil { + bin = p + break + } + } + if bin == "" { + return nil, fmt.Errorf("браузер для печати в PDF не найден") + } + dir, err := os.MkdirTemp("", "bj-pdf-") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + in, out := filepath.Join(dir, "page.html"), filepath.Join(dir, "page.pdf") + if err := os.WriteFile(in, page, 0o600); err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, bin, "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", + "--no-pdf-header-footer", "--user-data-dir="+filepath.Join(dir, "profile"), "--print-to-pdf="+out, "file://"+in) + cmd.Env = append(os.Environ(), "HOME="+dir) + if msg, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(msg))) + } + return os.ReadFile(out) +} diff --git a/internal/lkgateway/nsddocs_live_test.go b/internal/lkgateway/nsddocs_live_test.go new file mode 100644 index 0000000..187f29e --- /dev/null +++ b/internal/lkgateway/nsddocs_live_test.go @@ -0,0 +1,30 @@ +//go:build live + +package lkgateway + +import ( + "archive/zip" + "bytes" + "os" + "testing" +) + +// Живой прогон: документы НРД по поручению 20608 (исходящий 16.09) → ZIP с папками, описью и PDF. +// go test -c -tags live ./internal/lkgateway/ ; запуск от bj: -test.run TestNSDDocsLive -test.v +func TestNSDDocsLive(t *testing.T) { + docs := nsdOrderDocs("20608") + for i, d := range docs { + t.Logf("%s | %v", nsdDocFolder(i, d), d.Fields) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № 20608", docs, true) + zw.Close() + zr, _ := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + for _, f := range zr.File { + if f.Name == "Опись.txt" || f.Name == "Документы НРД.pdf" || f.Name == "Документы НРД.html" { + t.Logf("в архиве: %s (%d байт)", f.Name, f.UncompressedSize64) + } + } + _ = os.WriteFile(os.TempDir()+"/nsd-docs-20608.zip", buf.Bytes(), 0o644) +} diff --git a/internal/lkgateway/nsddocs_test.go b/internal/lkgateway/nsddocs_test.go new file mode 100644 index 0000000..3411ffc --- /dev/null +++ b/internal/lkgateway/nsddocs_test.go @@ -0,0 +1,43 @@ +package lkgateway + +import ( + "strings" + "testing" +) + +func TestClassifyNSDDoc(t *testing.T) { + cases := []struct{ pkg, body, code, title string }{ + {"K1699092", `144622492026-09-16 16:03:5320608`, + "REG", "Квитанция о регистрации поручения"}, + {"K1", `12060812Нет встречного`, + "REG_REJECT", "Отказ в регистрации поручения"}, + {"50818777", `GS036 отсутствии встречного поручения к поручению депоКод операции16`, + "GS036", "Уведомление об отсутствии встречного поручения (GS036)"}, + {"50818779", `GS1162060814462249MACH`, + "GS116", "Отчёт о статусе поручения (GS116)"}, + {"50818781", `MS01020608DELI10HL171004001C`, + "MS010", "Отчёт об исполнении поручения (MS010)"}, + } + for _, c := range cases { + d := classifyNSDDoc(c.pkg, c.body) + if d.Code != c.code || d.Title != c.title { + t.Errorf("%s: %s «%s», ожидали %s «%s»", c.pkg, d.Code, d.Title, c.code, c.title) + } + } + ms := classifyNSDDoc("50818781", cases[4].body) + if !ms.Done || !strings.Contains(ms.About, "списание") { + t.Errorf("MS010: %+v", ms) + } + var acc string + for _, kv := range ms.Fields { + if kv[0] == "Счёт депо" { + acc = kv[1] + } + } + if acc != "HL171004001C" { + t.Errorf("счёт депо: %q (%v)", acc, ms.Fields) + } + if f := nsdDocFolder(1, classifyNSDDoc("50818777", cases[2].body)); f != "02 Уведомление об отсутствии встречного поручения (GS036) — 50818777" { + t.Errorf("папка: %q", f) + } +} diff --git a/internal/lkgateway/outgoing_export.go b/internal/lkgateway/outgoing_export.go index a8ad517..258945a 100644 --- a/internal/lkgateway/outgoing_export.go +++ b/internal/lkgateway/outgoing_export.go @@ -32,10 +32,8 @@ func exportOutgoing(w http.ResponseWriter, r *http.Request, deps outgoingDeps, g } } // Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX. - for _, rf := range collectOutgoingResponseRaw(deal.ContrDIDs) { - if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil { - _, _ = f.Write(rf.Data) - } + if docs := nsdOrderDocs(deal.ContrDIDs...); len(docs) > 0 { + writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16 (списание) № "+strings.Join(deal.ContrDIDs, ", "), docs, true) } } diff --git a/internal/lkgateway/outgoing_order.go b/internal/lkgateway/outgoing_order.go index 632c706..9405810 100644 --- a/internal/lkgateway/outgoing_order.go +++ b/internal/lkgateway/outgoing_order.go @@ -341,19 +341,15 @@ func registerOutgoing(mux *http.ServeMux, a *admin, deps outgoingDeps) { http.Error(w, "исходящее поручение не найдено", http.StatusNotFound) return } - files := collectOutgoingResponseRaw(sub.ContrDIDs) - if len(files) == 0 { + docs := nsdOrderDocs(sub.ContrDIDs...) + if len(docs) == 0 { http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", `attachment; filename="otvet-nrd-ms010-`+shortID(guid)+`.zip"`) zw := zip.NewWriter(w) - for _, f := range files { - if wf, e := zw.Create(f.Name); e == nil { - _, _ = wf.Write(f.Data) - } - } + writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № "+strings.Join(sub.ContrDIDs, ", "), docs, true) zw.Close() case strings.HasSuffix(p, "/export.zip"): exportOutgoing(w, r, deps, strings.TrimSuffix(p, "/export.zip"))