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) }