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" "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) } if deps.igwClient == nil || deps.igwChan == "" { redirect("ИШ не настроен — нельзя отправить поручение") return } // только .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 { redirect("Черновик поручения 16/1 (MF010) по этому переводу в INBOX ИШ не найден") return } ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) defer cancel() var sent, sentXML []string var firstErr, contrDID string for _, p := range xmls { raw, e := os.ReadFile(p) // windows-1251, шлём как есть if e != nil { firstErr = e.Error() continue } if contrDID == "" { contrDID = orderContrDID(raw) } 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, string(decodeCP1251(raw))) // сохраняем что отправили (UTF-8) } if len(sent) > 0 { deps.store.SetOrderSubmitted(id, contrDID, strings.Join(sent, ", "), strings.Join(sentXML, "\n\n")) if cur, ok := deps.store.Get(id); ok { go depoOnOrderSent(deps.rc, cur.GUID, []string{contrDID}, "16/1 (зачисление)") } } if len(sent) == 0 { msg := "не удалось" if firstErr != "" { msg = firstErr } redirect("Поручение 16/1 (ORDXM): отправка в НРД не удалась: " + msg) return } note := fmt.Sprintf("Поручение 16/1 (тип ЭД ORDXM) отправлено в НРД: пакет(ы) ИШ %s. "+ "Ждём отчёт по поручению депо (XMLRP) = «MS010». Ответ появится ниже на странице.", strings.Join(sent, ", ")) if firstErr != "" { note += " ⚠ часть файлов не ушла: " + firstErr } redirect(note) } // writeOrderResponseZip отдаёт на скачивание ZIP с сырыми файлами ответа НРД // (отчёт MS010 + квитанции) по поручению с данным contr_d_id. func writeOrderResponseZip(w http.ResponseWriter, contrDID, fname string) { files := collectOrderResponseRaw(contrDID) if len(files) == 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() for _, f := range files { if wf, e := zw.Create(f.Name); e == nil { _, _ = wf.Write(f.Data) } } } // 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 (операция проведена) } // 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 } entries, err := os.ReadDir(igwInboxDir) if err != nil { return nil } type rr struct { resp orderResponse mt time.Time } var tmp []rr 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 == "" || !strings.Contains(body, contrDID) { continue } kind, done := orderRespKind(dir, body) tmp = append(tmp, rr{orderResponse{ Pkg: dir, Kind: kind, Done: done, Time: mt.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05"), Text: orderRespSummary(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). Возвращает текст и время. func readPkgText(dir string) (string, time.Time) { var sb strings.Builder var mt time.Time files, _ := os.ReadDir(dir) 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"): return "Отчёт по поручению депо MS010 — операция проведена, бумаги зачислены", true 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 }