Документы НРД по поручению: папки с понятными названиями, опись и 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:
@@ -45,10 +45,8 @@ func exportIncoming(w http.ResponseWriter, r *http.Request, deps incomingDeps, i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
|
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
|
||||||
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
|
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
|
||||||
if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil {
|
writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16/1 (зачисление) № "+it.OrderContrDID, docs, true)
|
||||||
_, _ = f.Write(rf.Data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *Incoming
|
|||||||
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
|
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
|
||||||
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
|
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
|
||||||
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
|
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
|
||||||
files := collectOrderResponseRaw(contrDID)
|
docs := nsdOrderDocs(contrDID)
|
||||||
if len(files) == 0 {
|
if len(docs) == 0 {
|
||||||
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
||||||
return
|
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))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname))
|
||||||
zw := zip.NewWriter(w)
|
zw := zip.NewWriter(w)
|
||||||
defer zw.Close()
|
defer zw.Close()
|
||||||
for _, f := range files {
|
writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true)
|
||||||
if wf, e := zw.Create(f.Name); e == nil {
|
|
||||||
_, _ = wf.Write(f.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
|
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
|
||||||
@@ -360,8 +356,9 @@ func orderRespKind(pkg, body string) (string, bool) {
|
|||||||
u := strings.ToUpper(body)
|
u := strings.ToUpper(body)
|
||||||
low := strings.ToLower(body)
|
low := strings.ToLower(body)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "MS010") || strings.Contains(u, "SESE.025"):
|
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, "<reg_no>"):
|
||||||
return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true
|
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, "ошибк"):
|
case strings.Contains(u, "ERROR") || strings.Contains(strings.ToUpper(pkg), "ERROR") || strings.Contains(low, "ошибк"):
|
||||||
return "Ошибка обработки пакета (CERROR)", false
|
return "Ошибка обработки пакета (CERROR)", false
|
||||||
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
|
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
|
||||||
|
|||||||
@@ -115,10 +115,8 @@ func writeIncomingBundleZip(zw *zip.Writer, it *IncomingTransfer, prefix string)
|
|||||||
_, _ = f.Write(faithfulXMLBytes(it.OrderXML))
|
_, _ = f.Write(faithfulXMLBytes(it.OrderXML))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, rf := range collectOrderResponseRaw(it.OrderContrDID) {
|
if docs := nsdOrderDocs(it.OrderContrDID); len(docs) > 0 {
|
||||||
if f, e := zw.Create(prefix + "otvet-nrd/" + rf.Name); e == nil {
|
writeNSDDocsZip(zw, prefix+"Ответ НРД/", "Документы НРД по поручению 16/1 № "+it.OrderContrDID, docs, false)
|
||||||
_, _ = f.Write(rf.Data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifyNSDDoc(t *testing.T) {
|
||||||
|
cases := []struct{ pkg, body, code, title string }{
|
||||||
|
{"K1699092", `<Batch><Document><reg_no>14462249</reg_no><reg_date>2026-09-16 16:03:53</reg_date><contragent_doc_id>20608</contragent_doc_id><abort_code /><abort_cause /></Document></Batch>`,
|
||||||
|
"REG", "Квитанция о регистрации поручения"},
|
||||||
|
{"K1", `<Batch><Document><reg_no>1</reg_no><contragent_doc_id>20608</contragent_doc_id><abort_code>12</abort_code><abort_cause>Нет встречного</abort_cause></Document></Batch>`,
|
||||||
|
"REG_REJECT", "Отказ в регистрации поручения"},
|
||||||
|
{"50818777", `<BizSvc>GS036</BizSvc><GS036><Ntfctns><NtfctnTxt> отсутствии встречного поручения к поручению депо</NtfctnTxt></Ntfctns><Ntfctns><NtfctnHdr>Код операции</NtfctnHdr><NtfctnTxt>16</NtfctnTxt></Ntfctns></GS036>`,
|
||||||
|
"GS036", "Уведомление об отсутствии встречного поручения (GS036)"},
|
||||||
|
{"50818779", `<BizSvc>GS116</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><AcctSvcrTxId>14462249</AcctSvcrTxId><MtchgSts><Mtchd><Rsn><Id>MACH</Id></Rsn></Mtchd></MtchgSts>`,
|
||||||
|
"GS116", "Отчёт о статусе поручения (GS116)"},
|
||||||
|
{"50818781", `<BizSvc>MS010</BizSvc><AcctOwnrTxId>20608</AcctOwnrTxId><SctiesMvmntTp>DELI</SctiesMvmntTp><SttldQty><Qty><Unit>10</Unit></Qty></SttldQty><SfkpgAcct><Id>HL171004001C</Id></SfkpgAcct>`,
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,10 +32,8 @@ func exportOutgoing(w http.ResponseWriter, r *http.Request, deps outgoingDeps, g
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
|
// Ответ НРД по поручению (отчёт MS010 + квитанции) — сырые файлы из INBOX.
|
||||||
for _, rf := range collectOutgoingResponseRaw(deal.ContrDIDs) {
|
if docs := nsdOrderDocs(deal.ContrDIDs...); len(docs) > 0 {
|
||||||
if f, e := zw.Create("otvet-nrd/" + rf.Name); e == nil {
|
writeNSDDocsZip(zw, "Ответ НРД/", "Документы НРД по поручению 16 (списание) № "+strings.Join(deal.ContrDIDs, ", "), docs, true)
|
||||||
_, _ = f.Write(rf.Data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -341,19 +341,15 @@ func registerOutgoing(mux *http.ServeMux, a *admin, deps outgoingDeps) {
|
|||||||
http.Error(w, "исходящее поручение не найдено", http.StatusNotFound)
|
http.Error(w, "исходящее поручение не найдено", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
files := collectOutgoingResponseRaw(sub.ContrDIDs)
|
docs := nsdOrderDocs(sub.ContrDIDs...)
|
||||||
if len(files) == 0 {
|
if len(docs) == 0 {
|
||||||
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="otvet-nrd-ms010-`+shortID(guid)+`.zip"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="otvet-nrd-ms010-`+shortID(guid)+`.zip"`)
|
||||||
zw := zip.NewWriter(w)
|
zw := zip.NewWriter(w)
|
||||||
for _, f := range files {
|
writeNSDDocsZip(zw, "", "Документы НРД по поручению 16 (списание) № "+strings.Join(sub.ContrDIDs, ", "), docs, true)
|
||||||
if wf, e := zw.Create(f.Name); e == nil {
|
|
||||||
_, _ = wf.Write(f.Data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zw.Close()
|
zw.Close()
|
||||||
case strings.HasSuffix(p, "/export.zip"):
|
case strings.HasSuffix(p, "/export.zip"):
|
||||||
exportOutgoing(w, r, deps, strings.TrimSuffix(p, "/export.zip"))
|
exportOutgoing(w, r, deps, strings.TrimSuffix(p, "/export.zip"))
|
||||||
|
|||||||
Reference in New Issue
Block a user