package lkgateway // incoming_order.go — подача депозитарного поручения 16/1 (форма MF010) в НРД // как классического поручения депо (тип ЭД ORDXM, config.xml K). // После согласия по 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(`\s*([^<\s]+)\s*`) 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("ИШ не настроен — нельзя отправить поручение") } // защита от параллельной/повторной подачи (авто-поллер + ручная кнопка/двойной клик) if !beginSubmit("in:" + it.ID) { return nil, "", fmt.Errorf("поручение 16/1 по этому переводу уже отправляется (параллельный запрос) — повторная подача отменена") } defer endSubmit("in:" + it.ID) if cur, ok := deps.store.Get(it.ID); ok && cur.OrderSubmitted && strings.TrimSpace(cur.OrderPkgs) != "" { // уже подано ранее — в НРД повторно НЕ шлём (идемпотентность) return strings.Split(cur.OrderPkgs, ", "), "", nil } // только .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, ""): 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 }