Files
Bridge-and-Join-s/internal/lkgateway/nsddocs.go
T
zuevav 28c0ed7349 Документы НРД по поручению: папки с понятными названиями, опись и 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>
2026-09-16 16:26:25 +03:00

364 lines
15 KiB
Go
Raw 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
// 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)
}