Документы НРД по поручению: папки с понятными названиями, опись и PDF

Пакеты НРД из INBOX ИШ (K…, 508…) распознаются по содержимому: квитанция о регистрации (или отказ),
уведомление GS036, отчёт о статусе GS116 (сквитовано / не сквитовано), отчёт об исполнении MS010,
ошибка. В ZIP ответа НРД и выгрузках для регулятора (16 и 16/1) — папки «01 Квитанция о регистрации
поручения — K1699092» и т. д., «Опись.txt» с назначением и реквизитами каждого документа и
«Документы НРД.pdf» (читаемое представление, печать через Chromium; без него — HTML). В карточке
перевода ответы НРД подписаны так же.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 16:26:25 +03:00
parent 6a2e9b7122
commit 28c0ed7349
8 changed files with 451 additions and 28 deletions
+363
View File
@@ -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*</(?:\w+:)?`+name+`>`))
}
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)<OthrId>\s*<Id>([^<]+)</Id>`)
sfkpgAcctRe = regexp.MustCompile(`(?s)<SfkpgAcct>\s*<Id>([^<]+)</Id>`)
gs036PairRe = regexp.MustCompile(`(?s)<Ntfctns>\s*(?:<NtfctnHdr>([^<]*)</NtfctnHdr>)?\s*(?:<NtfctnTxt>([^<]*)</NtfctnTxt>)?\s*</Ntfctns>`)
prtryIDRe = regexp.MustCompile(`(?s)<(Rcvg|Dlvrg)SttlmPties>.*?<PrtryId>\s*<Id>([^<]+)</Id>`)
qtyUnitRe = regexp.MustCompile(`(?s)<(?:SttldQty|SttlmQty)>\s*<Qty>\s*<(?:Unit|FaceAmt|AmtsdVal)>([^<]+)<`)
dateInRe = regexp.MustCompile(`(?s)<(FctvSttlmDt|SttlmDt)>\s*<Dt>\s*<Dt>([^<]+)</Dt>`)
)
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, "<Mtchd>"):
d.About = "Поручение сквитовано со встречным поручением контрагента (MACH) — НРД переходит к исполнению."
addField(&d, "Статус сверки", "сквитовано (MACH)")
case strings.Contains(body, "<NMtchd>"):
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, "<reg_no>") || strings.Contains(body, "<contragent_doc_id>"):
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(`<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>` + esc(title) + `</title><style>`)
b.WriteString(`@page{size:A4;margin:16mm 14mm}body{font-family:"PT Sans","DejaVu Sans",Arial,sans-serif;color:#1a1a1a;font-size:12px;line-height:1.45}`)
b.WriteString(`h1{font-size:18px;margin:0 0 4px}.sub{color:#666;margin:0 0 14px}.doc{border:1px solid #cfd4dc;border-radius:8px;padding:10px 12px;margin:0 0 12px;break-inside:avoid}`)
b.WriteString(`.doc h2{font-size:14px;margin:0 0 2px}.meta{color:#666;font-size:11px;margin-bottom:6px}.about{margin:4px 0 8px}`)
b.WriteString(`table{border-collapse:collapse;width:100%}td{border-top:1px solid #e4e7ec;padding:4px 6px;vertical-align:top}td.k{color:#555;width:38%}`)
b.WriteString(`.ok{color:#0f7a37}.bad{color:#b3261e}.brand{border-bottom:2px solid #ff0508;padding-bottom:8px;margin-bottom:12px}</style></head><body>`)
b.WriteString(`<div class="brand"><h1>` + esc(title) + `</h1><p class="sub">Документы НРД по депо-поручению в порядке поступления · сформировано bj ` + esc(mskTime(time.Now())) + `</p></div>`)
for i, d := range docs {
cls := ""
switch {
case d.Done:
cls = "ok"
case d.Rejected:
cls = "bad"
}
fmt.Fprintf(&b, `<div class="doc"><h2 class="%s">%d. %s</h2><div class="meta">пакет НРД %s · получен %s</div><div class="about">%s</div>`,
cls, i+1, esc(d.Title), esc(d.Pkg), esc(mskTime(d.Time)), esc(d.About))
if len(d.Fields) > 0 {
b.WriteString("<table>")
for _, kv := range d.Fields {
fmt.Fprintf(&b, `<tr><td class="k">%s</td><td>%s</td></tr>`, esc(kv[0]), esc(kv[1]))
}
b.WriteString("</table>")
}
b.WriteString("</div>")
}
b.WriteString(`<p class="sub">Подлинные документы — файлы в папках архива (XML с подписью НРД .SGN). Этот PDF — их читаемое представление.</p></body></html>`)
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)
}