98e17a8cb7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
494 lines
19 KiB
Go
494 lines
19 KiB
Go
package lkgateway
|
|
|
|
// incoming_order.go — подача депозитарного поручения 16/1 (форма MF010) в НРД
|
|
// как классического поручения депо (тип ЭД ORDXM, config.xml <package>K</package>).
|
|
// После согласия по M2M НРД кладёт нам в INBOX черновик поручения 16/1; здесь мы
|
|
// отправляем его в НРД через ИШ (WSL-канал). НРД проводит зачисление и присылает
|
|
// отчёт по поручению депо (тип XMLRP) — это и есть ожидаемый контрагентом «MS010».
|
|
// ИШ подписывает документ ГОСТ-подписью при отправке через REST API.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
|
)
|
|
|
|
var (
|
|
reContrDID = regexp.MustCompile(`<contr_d_id>\s*([^<\s]+)\s*</contr_d_id>`)
|
|
reTags = regexp.MustCompile(`<[^>]+>`)
|
|
)
|
|
|
|
func decodeCP1251(b []byte) []byte { return nsdxml.DecodeWindows1251(b) }
|
|
|
|
// faithfulXMLBytes отдаёт XML в кодировке, заявленной в его декларации: если это
|
|
// windows-1251 (как у файлов НРД), перекодирует UTF-8-строку обратно в 1251 —
|
|
// чтобы сохранённый файл открывался согласно своей же декларации. Иначе — как есть.
|
|
func faithfulXMLBytes(s string) []byte {
|
|
head := s
|
|
if len(head) > 120 {
|
|
head = head[:120]
|
|
}
|
|
if strings.Contains(strings.ToLower(head), "windows-1251") {
|
|
if enc, err := nsdxml.EncodeWindows1251([]byte(s)); err == nil {
|
|
return enc
|
|
}
|
|
}
|
|
return []byte(s)
|
|
}
|
|
|
|
// submitDepoOrder отправляет поручение депо 16/1 (черновик MF010 из INBOX) в НРД
|
|
// типом ЭД ORDXM. По каждой бумаге НРД сформировал отдельный черновик — шлём все.
|
|
func submitDepoOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
it, ok := deps.store.Get(id)
|
|
if !ok {
|
|
http.Error(w, "входящий перевод не найден", http.StatusNotFound)
|
|
return
|
|
}
|
|
redirect := func(msg string) {
|
|
http.Redirect(w, r, "/admin/incoming/"+id+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second)
|
|
defer cancel()
|
|
sent, firstErr, err := submitIncomingOrderFor(ctx, deps, it)
|
|
if err != nil {
|
|
redirect(err.Error())
|
|
return
|
|
}
|
|
note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+
|
|
"Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", "))
|
|
if firstErr != "" {
|
|
note += " ⚠ часть файлов не ушла: " + firstErr
|
|
}
|
|
redirect(note)
|
|
}
|
|
|
|
// submitIncomingOrderFor — подать поручение 16/1 (ORDXM) по черновику MF010 из INBOX ИШ (кнопка в
|
|
// карточке и автоматический режим). firstErr — если часть файлов не ушла.
|
|
func submitIncomingOrderFor(ctx context.Context, deps incomingDeps, it *IncomingTransfer) (sent []string, firstErr string, err error) {
|
|
if deps.igwClient == nil || deps.igwChan == "" {
|
|
return nil, "", fmt.Errorf("ИШ не настроен — нельзя отправить поручение")
|
|
}
|
|
// только .xml (без .SGN) — подпись НРД нам не нужна, ИШ подпишет своей ГОСТ.
|
|
var xmls []string
|
|
for _, p := range findMF010Files(it.GUID) {
|
|
if strings.HasSuffix(strings.ToUpper(p), ".XML") {
|
|
xmls = append(xmls, p)
|
|
}
|
|
}
|
|
if len(xmls) == 0 {
|
|
return nil, "", fmt.Errorf("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден")
|
|
}
|
|
id := it.ID
|
|
var sentXML []string
|
|
var contrDID string
|
|
// Одно MF010 = одна бумага пакета: запоминаем каждую строку (contr_d_id ↔ ISIN ↔
|
|
// REFERENCE), чтобы исход по MS010 писать в DEPO по своей строке.
|
|
refByISIN := map[string]string{}
|
|
for _, s := range it.Securities {
|
|
if s.ISIN != "" {
|
|
refByISIN[strings.ToUpper(s.ISIN)] = s.ReferenceID
|
|
}
|
|
}
|
|
var lines []IncomingOrderLine
|
|
for _, p := range xmls {
|
|
raw, e := os.ReadFile(p) // windows-1251, шлём как есть
|
|
if e != nil {
|
|
firstErr = e.Error()
|
|
continue
|
|
}
|
|
body := string(decodeCP1251(raw))
|
|
cd := orderContrDID(raw)
|
|
if contrDID == "" {
|
|
contrDID = cd
|
|
}
|
|
zipBody, e := igw.PackDepoOrder(raw, filepath.Base(p))
|
|
if e != nil {
|
|
firstErr = e.Error()
|
|
continue
|
|
}
|
|
pkgID, e := deps.igwClient.SendPackage(ctx, deps.igwChan, "K", zipBody)
|
|
if e != nil {
|
|
firstErr = e.Error()
|
|
continue
|
|
}
|
|
sent = append(sent, pkgID)
|
|
sentXML = append(sentXML, body) // сохраняем что отправили (UTF-8)
|
|
isin := strings.ToUpper(of16(body, "security_isin"))
|
|
lines = append(lines, IncomingOrderLine{ContrDID: cd, ISIN: isin, Ref: refByISIN[isin]})
|
|
}
|
|
if len(sent) > 0 {
|
|
deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n"), lines)
|
|
if cur, ok := deps.store.Get(id); ok {
|
|
for _, l := range lines {
|
|
go depoOnOrderLineSent(deps.rc, cur.GUID, l.Ref, l.ContrDID)
|
|
}
|
|
}
|
|
}
|
|
if len(sent) == 0 {
|
|
msg := "не удалось"
|
|
if firstErr != "" {
|
|
msg = firstErr
|
|
}
|
|
return nil, "", fmt.Errorf("Поручение 16/1 (ORDXM): отправка в НРД не удалась: %s", msg)
|
|
}
|
|
return sent, firstErr, nil
|
|
}
|
|
|
|
// writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД
|
|
// (отчёт MS010 + квитанции) по поручению с данным contr_d_id.
|
|
func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) {
|
|
docs := nsdOrderDocs(contrDID)
|
|
if len(docs) == 0 {
|
|
http.Error(w, "ответ НРД (MS010) по этому поручению в INBOX ИШ ещё не найден", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, fname))
|
|
zw := zip.NewWriter(w)
|
|
defer zw.Close()
|
|
writeNSDDocsZip(zw, "", "Документы НРД по поручению № "+contrDID, docs, true)
|
|
}
|
|
|
|
// downloadSentOrder отдаёт на скачивание XML отправленного поручения 16/1
|
|
// (что именно мы отправили в НРД).
|
|
func downloadSentOrder(w http.ResponseWriter, r *http.Request, deps incomingDeps, id string) {
|
|
it, ok := deps.store.Get(id)
|
|
if !ok || strings.TrimSpace(it.OrderXML) == "" {
|
|
http.Error(w, "отправленное поручение 16/1 по этому переводу не найдено", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="poruchenie-16-1-%s.xml"`, shortID(it.ID)))
|
|
_, _ = w.Write(faithfulXMLBytes(it.OrderXML))
|
|
}
|
|
|
|
// draftReceivedTime — время появления черновика 16/1 (MF010) в INBOX ИШ (МСК),
|
|
// то есть когда НРД сформировал и прислал нам черновик поручения. "" если нет.
|
|
func draftReceivedTime(guid string) string {
|
|
var mt time.Time
|
|
for _, p := range findMF010Files(guid) {
|
|
if info, e := os.Stat(p); e == nil {
|
|
if mt.IsZero() || info.ModTime().Before(mt) {
|
|
mt = info.ModTime()
|
|
}
|
|
}
|
|
}
|
|
if mt.IsZero() {
|
|
return ""
|
|
}
|
|
return mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
|
|
}
|
|
|
|
// orderContrDID достаёт contr_d_id (исходящий номер поручения) из XML черновика.
|
|
func orderContrDID(raw []byte) string {
|
|
t := string(raw)
|
|
m := reContrDID.FindStringSubmatch(t)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// orderResponse — ответ НРД по поданному поручению депо (найден в INBOX ИШ).
|
|
type orderResponse struct {
|
|
Pkg string // имя пакета
|
|
Kind string // человекочитаемый тип (квитанция / принятие / отчёт MS010)
|
|
Time string
|
|
Text string // краткое содержимое
|
|
Done bool // true — это отчёт MS010 (операция проведена или завершена отказом)
|
|
// Rejected — MS010 с отказом: операция завершилась, но не исполнена. Показывается красным.
|
|
Rejected bool
|
|
// No — номер документа НРД (BizMsgIdr), для отчёта MS010 — «номер ответа НРД» в PARAMETERS событий DEPO.
|
|
No string
|
|
}
|
|
|
|
// findOrderResponses ищет в INBOX ИШ ответы НРД по нашему поручению (по contr_d_id).
|
|
// Читает как распакованные файлы, так и содержимое zip-архивов (отчёты НРД типа
|
|
// MS010 приходят dbf+repxml.xml внутри zip, имя пакета числовое). Исключает наши
|
|
// собственные #M2M-пакеты (черновик/запрос).
|
|
func findOrderResponses(contrDID string) []orderResponse {
|
|
contrDID = strings.TrimSpace(contrDID)
|
|
if contrDID == "" {
|
|
return nil
|
|
}
|
|
type rr struct {
|
|
resp orderResponse
|
|
mt time.Time
|
|
}
|
|
var tmp []rr
|
|
for _, pk := range pkgIndex() {
|
|
dir, body, mt := pk.dir, pk.body, pk.mt
|
|
if !strings.Contains(body, contrDID) {
|
|
continue
|
|
}
|
|
kind, done := orderRespKind(dir, body)
|
|
tmp = append(tmp, rr{orderResponse{
|
|
Pkg: dir, Kind: kind, Done: done, Rejected: done && respTextRejected(body),
|
|
Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"),
|
|
Text: orderRespSummary(body),
|
|
No: nsdRespNoDate(body),
|
|
}, mt})
|
|
}
|
|
sort.Slice(tmp, func(i, j int) bool { return tmp[i].mt.Before(tmp[j].mt) })
|
|
out := make([]orderResponse, len(tmp))
|
|
for i := range tmp {
|
|
out[i] = tmp[i].resp
|
|
}
|
|
return out
|
|
}
|
|
|
|
// respFile — сырой файл ответа НРД (для скачивания и выгрузки регулятору).
|
|
type respFile struct {
|
|
Name string // имя внутри архива (пакет/файл)
|
|
Data []byte
|
|
}
|
|
|
|
// collectOrderResponseRaw собирает СЫРЫЕ файлы ответов НРД по нашему поручению
|
|
// (по contr_d_id): отчёт MS010 (repxml.xml/ms010.rpt/dbf внутри zip), квитанции,
|
|
// уведомления о принятии. Именно эти файлы важны регулятору как подтверждение,
|
|
// что НРД получил и обработал поручение.
|
|
func collectOrderResponseRaw(contrDID string) []respFile {
|
|
contrDID = strings.TrimSpace(contrDID)
|
|
if contrDID == "" {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(igwInboxDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []respFile
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
dir := e.Name()
|
|
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
|
|
continue
|
|
}
|
|
body, _ := readPkgText(filepath.Join(igwInboxDir, dir))
|
|
if body == "" || !strings.Contains(body, contrDID) {
|
|
continue
|
|
}
|
|
files, _ := os.ReadDir(filepath.Join(igwInboxDir, dir))
|
|
for _, f := range files {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
p := filepath.Join(igwInboxDir, 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: dir + "/" + 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: dir + "/" + zf.Name, Data: data})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// readPkgText собирает текст всех документов пакета: распакованные xml/htm/rpt и
|
|
// содержимое вложенных zip (декодирует windows-1251). Возвращает текст и время.
|
|
// pkgIndex — все пакеты-ответы во входящем каталоге ИШ с их текстом, одним проходом и с
|
|
// коротким кэшем: поиск ответов по номерам поручений идёт по памяти, а не по диску на
|
|
// каждый номер (сотни номеров × две тысячи каталогов давали сотни миллисекунд на страницу).
|
|
type pkgIndexEntry struct {
|
|
dir, body string
|
|
mt time.Time
|
|
}
|
|
|
|
var pkgIndexCache = struct {
|
|
mu sync.Mutex
|
|
at time.Time
|
|
list []pkgIndexEntry
|
|
}{}
|
|
|
|
func pkgIndex() []pkgIndexEntry {
|
|
pkgIndexCache.mu.Lock()
|
|
defer pkgIndexCache.mu.Unlock()
|
|
if pkgIndexCache.list != nil && time.Since(pkgIndexCache.at) < 3*time.Second {
|
|
return pkgIndexCache.list
|
|
}
|
|
entries, err := os.ReadDir(igwInboxDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
list := make([]pkgIndexEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
dir := e.Name()
|
|
if strings.HasPrefix(dir, "#") || dir == "INBOX" { // наши M2M-пакеты — не ответ
|
|
continue
|
|
}
|
|
body, mt := readPkgText(filepath.Join(igwInboxDir, dir))
|
|
if body == "" {
|
|
continue
|
|
}
|
|
list = append(list, pkgIndexEntry{dir: dir, body: body, mt: mt})
|
|
}
|
|
pkgIndexCache.list, pkgIndexCache.at = list, time.Now()
|
|
return list
|
|
}
|
|
|
|
// pkgTextCache — разобранный текст пакетов ИШ по каталогу: пакет после получения не меняется,
|
|
// поэтому перечитывать его файлы при каждом обходе журнала незачем. Ключ проверки — время
|
|
// изменения каталога пакета и число файлов в нём.
|
|
var pkgTextCache = struct {
|
|
mu sync.Mutex
|
|
m map[string]pkgTextEntry
|
|
}{m: map[string]pkgTextEntry{}}
|
|
|
|
type pkgTextEntry struct {
|
|
dirMod time.Time
|
|
n int
|
|
body string
|
|
mt time.Time
|
|
}
|
|
|
|
func readPkgText(dir string) (string, time.Time) {
|
|
var dirMod time.Time
|
|
if st, err := os.Stat(dir); err == nil {
|
|
dirMod = st.ModTime()
|
|
}
|
|
files, _ := os.ReadDir(dir)
|
|
pkgTextCache.mu.Lock()
|
|
if e, ok := pkgTextCache.m[dir]; ok && e.dirMod.Equal(dirMod) && e.n == len(files) {
|
|
pkgTextCache.mu.Unlock()
|
|
return e.body, e.mt
|
|
}
|
|
pkgTextCache.mu.Unlock()
|
|
body, mt := readPkgTextUncached(dir, files)
|
|
pkgTextCache.mu.Lock()
|
|
if len(pkgTextCache.m) > 20000 { // защита от бесконечного роста при чистке каталога
|
|
pkgTextCache.m = map[string]pkgTextEntry{}
|
|
}
|
|
pkgTextCache.m[dir] = pkgTextEntry{dirMod: dirMod, n: len(files), body: body, mt: mt}
|
|
pkgTextCache.mu.Unlock()
|
|
return body, mt
|
|
}
|
|
|
|
func readPkgTextUncached(dir string, files []os.DirEntry) (string, time.Time) {
|
|
var sb strings.Builder
|
|
var mt time.Time
|
|
for _, f := range files {
|
|
if f.IsDir() {
|
|
continue
|
|
}
|
|
p := filepath.Join(dir, f.Name())
|
|
if info, e := f.Info(); e == nil && info.ModTime().After(mt) {
|
|
mt = info.ModTime()
|
|
}
|
|
low := strings.ToLower(f.Name())
|
|
switch {
|
|
case strings.HasSuffix(low, ".xml"), strings.HasSuffix(low, ".htm"), strings.HasSuffix(low, ".rpt"):
|
|
if b, e := os.ReadFile(p); e == nil {
|
|
sb.Write(decodeCP1251(b))
|
|
sb.WriteByte('\n')
|
|
}
|
|
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 {
|
|
zl := strings.ToLower(zf.Name)
|
|
if strings.HasSuffix(zl, ".xml") || strings.HasSuffix(zl, ".rpt") || strings.HasSuffix(zl, ".htm") {
|
|
if rc, e3 := zf.Open(); e3 == nil {
|
|
data, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
sb.Write(decodeCP1251(data))
|
|
sb.WriteByte('\n')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return sb.String(), mt
|
|
}
|
|
|
|
// orderRespKind классифицирует ответ НРД по содержимому (надёжнее префикса имени).
|
|
// done=true — это отчёт MS010 (операция проведена).
|
|
func orderRespKind(pkg, body string) (string, bool) {
|
|
u := strings.ToUpper(body)
|
|
low := strings.ToLower(body)
|
|
switch {
|
|
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>"):
|
|
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, "ошибк"):
|
|
return "Ошибка обработки пакета (CERROR)", false
|
|
case strings.Contains(u, "REORD") || strings.Contains(low, "принят"):
|
|
return "Уведомление о принятии поручения депо (REORD)", false
|
|
case strings.Contains(u, "XCONF") || strings.Contains(low, "квитанц") || strings.Contains(low, "получен"):
|
|
return "Квитанция о получении пакета (XCONF)", false
|
|
}
|
|
// fallback по первой букве имени пакета
|
|
switch {
|
|
case strings.HasPrefix(pkg, "K"):
|
|
return "Уведомление о принятии поручения депо (REORD)", false
|
|
case strings.HasPrefix(pkg, "C"):
|
|
return "Квитанция о получении пакета (XCONF)", false
|
|
}
|
|
return "Ответ НРД (" + pkg + ")", false
|
|
}
|
|
|
|
func orderRespSummary(body string) string {
|
|
txt := reTags.ReplaceAllString(body, " ")
|
|
txt = strings.Join(strings.Fields(txt), " ")
|
|
if len(txt) > 220 {
|
|
txt = txt[:220]
|
|
}
|
|
return txt
|
|
}
|
|
|
|
// respTextRejected — в отчёте НРД сказано об отказе, а не об исполнении.
|
|
func respTextRejected(body string) bool {
|
|
low := strings.ToLower(body)
|
|
return strings.Contains(low, "отказ") || strings.Contains(low, "reject") || strings.Contains(low, "не исполнен")
|
|
}
|
|
|
|
// nsdRespNoDate — «номер от дата» документа НРД: BizMsgIdr и дата CreDt (МСК), например «50878767 от 29.09.2026».
|
|
func nsdRespNoDate(body string) string {
|
|
no := strings.TrimSpace(xmlTag(body, "BizMsgIdr"))
|
|
if no == "" {
|
|
return ""
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(xmlTag(body, "CreDt"))); err == nil {
|
|
return no + " от " + t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006")
|
|
}
|
|
return no
|
|
}
|