Files
Bridge-and-Join-s/internal/lkgateway/nsddocs.go
T
fontvielle c476cfd7a3 ЛК-карточка и M2M: операторский путь заявки, баннеры «в НРД не отправлено», дубль-поручение, idempotency, фикс DEPO-алертов
- Карточка заявки: операторский «Путь заявки» (claimTimelineOperator) короткими
  понятными вехами + сворачиваемый технический журнал (buildClaimTimeline);
  QUIK показан пошагово (register/sign/deny/deposit) с верными временами, отказ
  регистрации больше не выглядит успехом (quikActionFailTitles).
- Баннеры: «в НРД не отправлено», когда пакет сформирован, но не ушёл (нет
  submitted_to_nsd и не Fail); заголовок карточки и бейдж учитывают это; красный
  баннер QUIK при отказе, строгий «нет бумаг» только при реальном шорт/недостатке.
- НРД-ответ «Поручение с исходящим номером … уже существует» теперь REG_DUP
  (предупреждение-дубль, не красный «Отказ в регистрации»); не влияет на od.Rejected.
- Idempotency: submitguard.go (inflightSubmit) + защита точек подачи от
  двойного срабатывания (manual + auto-loop, двойной клик): submitOutgoingFor,
  submitIncomingOrderFor, QuikOutRegister (ранний возврат при registered/signed).
- DEPO-алерты: водяной знак depoFailAlertedID засеивается текущим MAX(ID) на
  старте процесса (Hlth03) — после рестарта bj больше не поднимает повторно
  алерты про уже известные ошибки обработки событий BJ_*.
- Тесты: claimnotsent_render_test.go (хелперы + рендер карточки).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-15 17:47:28 +03:00

382 lines
17 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 != "" {
if isDuplicateOrderAbort(cause) {
// НРД отклонил ПОВТОРНУЮ доставку того же поручения по исходящему
// номеру — защита от двойного исполнения (идемпотентность). Исходный
// экземпляр принят отдельной квитанцией; на статус перевода НЕ влияет,
// поэтому это НЕ отказ (Rejected=false), а информ* уведомление.
d.Code = "REG_DUP"
d.Title = "Повторная доставка поручения отклонена (дубликат)"
d.About = "НРД отклонил повторную доставку того же поручения по исходящему номеру (защита от двойного исполнения). Исходный экземпляр зарегистрирован и обрабатывается — на перевод не влияет."
} else {
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)
}
// isDuplicateOrderAbort — причина отказа НРД означает повторную доставку того же
// поручения по исходящему номеру (НРД отсекает дубль — защита от двойного
// исполнения), а не содержательный отказ. Такой ответ не влияет на статус перевода.
func isDuplicateOrderAbort(cause string) bool {
c := strings.ToLower(cause)
return strings.Contains(c, "уже существ")
}