c476cfd7a3
- Карточка заявки: операторский «Путь заявки» (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
382 lines
17 KiB
Go
382 lines
17 KiB
Go
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, "уже существ")
|
||
}
|